Compare commits

...
Sign in to create a new pull request.

80 commits

Author SHA1 Message Date
fa3bca29df FEATURE(account): adding promotions client
adding promotions client
2026-07-26 10:58:59 +03:30
0973d08637 FIX(account): fix duplicated invite code
fix duplicated invite code
2026-07-25 17:16:18 +03:30
f6099af0e4 FIX(account): add InviteRequest admin panel
add InviteRequest admin panel
2026-07-25 16:56:44 +03:30
2464eb0de8 FIX(account): fix application in request
fix application in request
2026-07-25 16:45:53 +03:30
586068fe3b FEATURE(account): adding InviteRequestView
adding InviteRequestView
2026-07-22 17:33:45 +03:30
86b02ffa73 FEATURE(account): adding InviteRequestView
adding InviteRequestView
2026-07-22 17:32:30 +03:30
e6ea563d19 FEATURE(accounts): provide all users endpoint
provide all users endpoint
2026-06-03 17:08:17 +03:30
Sayyid Hamid Mahdavi
cc6a76581e float ttl 2026-06-02 14:14:17 +03:30
f4fe506aef Fix(accounts): fix send_sms_by_uuid_list.py
fix send_sms_by_uuid_list.py
2026-05-25 17:47:52 +03:30
55dadd67f4 Merge branch 'master' of https://git.mindplus-dev.ir/sahama/accounts 2026-05-25 17:31:24 +03:30
926e48a5dd Fix(accounts): fix send_sms_by_uuid_list.py
fix send_sms_by_uuid_list.py
2026-05-25 17:28:24 +03:30
Sayyid Hamid Mahdavi
30f23c8d49 Merge branch 'master' of git.mindplus-dev.ir:sahama/accounts 2026-05-25 11:57:21 +03:30
43b5ba9028 Fix(accounts): send sms
send sms
2026-05-23 13:55:47 +03:30
706de57427 Fix(accounts): Dockerfile
Dockerfile
2026-05-20 13:08:56 +03:30
8859fe4d20 FEATURE(account): convert phone number to english numbers
convert phone number to english numbers
2026-05-18 11:37:58 +03:30
Sayyid Hamid Mahdavi
1981122f28 float ttl 2026-04-29 17:00:56 +03:30
Sayyid Hamid Mahdavi
28ae006b5d int ttl 2026-04-28 16:42:17 +03:30
Sayyid Hamid Mahdavi
f400eb6ab4 merge from upstream 2026-04-28 16:36:46 +03:30
Sayyid Hamid Mahdavi
280b8f1c2c throttles 2026-04-28 15:59:56 +03:30
Sayyid Hamid Mahdavi
1f44f80a79 better exception 2026-04-28 14:53:37 +03:30
Sayyid Hamid Mahdavi
c227fda538 better exception 2026-04-28 14:48:54 +03:30
Sayyid Hamid Mahdavi
3dd62a0173 better exception 2026-04-25 02:10:47 +03:30
Sayyid Hamid Mahdavi
c35accc8ac less fields in user admin 2026-04-12 12:42:02 +03:30
Sayyid Hamid Mahdavi
3d587fb63d log instead of logs dir 2026-04-11 15:43:37 +03:30
Sayyid Hamid Mahdavi
5db1c6f070 logger 2026-04-11 15:28:05 +03:30
Sayyid Hamid Mahdavi
ada0391922 logger 2026-04-11 14:50:03 +03:30
Sayyid Hamid Mahdavi
3796a50ca8 rename main directory from accounts to main 2026-04-11 13:24:33 +03:30
Sayyid Hamid Mahdavi
0e9ca49ca0 conflict error 2026-04-11 12:55:06 +03:30
Sayyid Hamid Mahdavi
7256578ec7 conflict error 2026-04-08 11:30:07 +03:30
Sayyid Hamid Mahdavi
0365d17095 conflict error 2026-04-08 11:23:11 +03:30
Sayyid Hamid Mahdavi
492d06b5ba throttle 2026-04-08 10:49:14 +03:30
Sayyid Hamid Mahdavi
9f284fbfff throttle 2026-04-08 10:49:03 +03:30
Sayyid Hamid Mahdavi
5f0684d354 list session api 2026-04-07 14:47:22 +03:30
Sayyid Hamid Mahdavi
9c81505a46 list session api 2026-04-07 10:07:54 +03:30
Sayyid Hamid Mahdavi
be4df13c78 revoke token test 2026-04-07 09:47:00 +03:30
Sayyid Hamid Mahdavi
c1c55c45a6 bug fix 2026-04-06 14:34:54 +03:30
Sayyid Hamid Mahdavi
e1d0339759 limit tokens 2026-04-06 13:23:14 +03:30
Sayyid Hamid Mahdavi
25c53f3a67 store headers in access token detail 2026-03-22 17:28:49 +03:30
Sayyid Hamid Mahdavi
014c674961 store headers in access token detail 2026-03-22 17:10:59 +03:30
Sayyid Hamid Mahdavi
4d5f1983c7 edit text for sms 2026-03-17 15:10:56 +03:30
Sayyid Hamid Mahdavi
2d1079f0c2 get header data from prod 2026-03-17 15:03:58 +03:30
Sayyid Hamid Mahdavi
e1b34b2482 temporary break point 2026-03-17 14:19:12 +03:30
Sayyid Hamid Mahdavi
0cd2b96963 temporary break point 2026-03-17 13:44:59 +03:30
Sayyid Hamid Mahdavi
6dbb86d7ee sms policy 2026-03-16 16:18:15 +03:30
Sayyid Hamid Mahdavi
b786a957e4 sms policy 2026-03-16 16:10:23 +03:30
Sayyid Hamid Mahdavi
29e4b4054c sms policy 2026-03-16 16:02:22 +03:30
Sayyid Hamid Mahdavi
c0f65b713f sms policy 2026-03-16 15:55:54 +03:30
Sayyid Hamid Mahdavi
4b277bc95f mobin sms client 2026-03-16 13:43:59 +03:30
Sayyid Hamid Mahdavi
b8a57a0ce4 add test number 2026-02-23 11:31:38 +03:30
Sayyid Hamid Mahdavi
ba858baa85 create test account 2026-02-18 12:42:46 +03:30
Sayyid Hamid Mahdavi
dda068c018 admin 2026-02-15 11:33:42 +03:30
Sayyid Hamid Mahdavi
27faf23bec random password 2026-02-15 10:24:38 +03:30
Sayyid Hamid Mahdavi
d4bb5ed6b6 Dockerfile 2026-02-08 13:35:44 +03:30
Sayyid Hamid Mahdavi
b01dcdf055 Dockerfile 2026-02-08 13:18:00 +03:30
Sayyid Hamid Mahdavi
2b6d192032 Dockerfile 2026-02-08 11:50:59 +03:30
Sayyid Hamid Mahdavi
9b2dce70df Dockerfile 2026-02-07 17:55:55 +03:30
Sayyid Hamid Mahdavi
5afccf8fc4 Dockerfile 2026-02-07 15:56:17 +03:30
Sayyid Hamid Mahdavi
092125dc31 i18n 2026-02-03 14:29:43 +03:30
Sayyid Hamid Mahdavi
589a64b447 i18n 2026-02-03 14:27:00 +03:30
Sayyid Hamid Mahdavi
2f5284607b i18n 2026-02-03 14:22:36 +03:30
Sayyid Hamid Mahdavi
97b8dfdc21 i18n 2026-02-03 14:18:57 +03:30
Sayyid Hamid Mahdavi
b763d5ad4e i18n 2026-02-03 13:47:36 +03:30
Sayyid Hamid Mahdavi
3f5368cd82 minio 2026-02-03 13:43:02 +03:30
Sayyid Hamid Mahdavi
1a9397d906 csrf 2026-02-03 13:38:14 +03:30
Sayyid Hamid Mahdavi
f38f98068e i18n 2026-02-03 13:36:28 +03:30
Sayyid Hamid Mahdavi
952bb6107a upgrade packages 2026-01-04 10:19:42 +03:30
Sayyid Hamid Mahdavi
171096f9da upgrade packages 2026-01-04 10:17:19 +03:30
Sayyid Hamid Mahdavi
7b153cd8f6 CONN_MAX_AGE 2025-12-20 16:00:48 +03:30
Sayyid Hamid Mahdavi
88d7b691a3 CONN_MAX_AGE 2025-12-20 15:56:38 +03:30
Sayyid Hamid Mahdavi
30b3c93168 update requirements 2025-12-20 14:12:54 +03:30
Sayyid Hamid Mahdavi
673f525d30 connection pool 2025-12-20 14:04:05 +03:30
Sayyid Hamid Mahdavi
c966dd3171 connection pool 2025-12-20 11:34:13 +03:30
Sayyid Hamid Mahdavi
63a6da058e daphne 2025-12-16 18:05:45 +03:30
Sayyid Hamid Mahdavi
a5f5e1162b logger 2025-12-09 11:12:42 +03:30
Sayyid Hamid Mahdavi
119165f2e1 logger 2025-12-09 11:09:08 +03:30
Sayyid Hamid Mahdavi
d6fe8a93da random password 2025-12-04 16:49:37 +03:30
Sayyid Hamid Mahdavi
2fe322241a hash otp 2025-11-20 13:57:23 +03:30
Sayyid Hamid Mahdavi
0d59a34e06 hash otp 2025-11-18 15:49:08 +03:30
Sayyid Hamid Mahdavi
2cf5ab4cb7 hash otp 2025-11-18 15:44:41 +03:30
Sayyid Hamid Mahdavi
f2e8777e6d hash otp 2025-11-18 15:15:57 +03:30
106 changed files with 6840 additions and 322 deletions

2
.gitignore vendored
View file

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

View file

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

8
Dockerfile.build Normal file
View file

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

View file

@ -1,2 +1,17 @@
from django.contrib import admin from django.contrib import admin
from .models import SMSPolicy, Config
class SMSPolicyAdmin(admin.ModelAdmin):
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
class ConfigAdmin(admin.ModelAdmin):
list_display = ['key', 'value', 'value_type', 'get_value', 'description', 'comment']
admin.site.register(Config, ConfigAdmin)
admin.site.register(SMSPolicy, SMSPolicyAdmin)

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

@ -0,0 +1,31 @@
from functools import wraps
from django.utils import timezone
from apps.core.models import Config, ConfigValueChoices
from utils.exceptions import ServiceUnavailable
def service_availability(key:str):
def with_params(view_func):
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
print(key)
print(f"Executing {view_func.__name__} action")
service_is_available = Config.get_value_of(f"SERVICE_IS_AVAILABLE_{key.upper()}", "True", ConfigValueChoices.BOOLEAN)
config = Config.objects.get(key=f"SERVICE_IS_AVAILABLE_{key.upper()}")
if not service_is_available:
raise ServiceUnavailable(
detail={
# "code": 'service_unavailable_at_now',
# "timestamp": timezone.now().isoformat(),
"message": config.description or "این سرویس در حال حاضر در دسترس نیست"
},
)
return view_func(self, request, *args, **kwargs)
return wrapper
return with_params

View file

@ -0,0 +1,31 @@
# Generated by Django 6.0.2 on 2026-03-16 12:23
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
]
operations = [
migrations.CreateModel(
name='SMSPolicy',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('preferred', models.CharField(choices=[('fake', 'fake'), ('payam_sms', 'payam_sms'), ('mobin_sms', 'mobin_sms')], default='fake', max_length=16)),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL, unique=True)),
],
options={
'abstract': False,
},
),
]

View file

@ -0,0 +1,30 @@
# Generated by Django 6.0.2 on 2026-04-06 05:59
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Config',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('updated_at', models.DateTimeField(auto_now=True, db_index=True)),
('key', models.CharField(db_index=True, max_length=255, unique=True, verbose_name='key')),
('value', models.TextField(blank=True, null=True, verbose_name='value')),
('value_type', models.CharField(choices=[('INT', 'Integer'), ('FLOAT', 'Float'), ('BOOLEAN', 'Boolean'), ('STRING', 'String'), ('DATE', 'Date'), ('DATETIME', 'DateTime'), ('JSON', 'JSON')], max_length=64)),
('description', models.TextField(blank=True, null=True, verbose_name='description')),
('comment', models.TextField(blank=True, null=True, verbose_name='comment')),
],
options={
'abstract': False,
},
),
]

View file

@ -1,2 +1,106 @@
import logging
from django.db.models import TextChoices
from apps.gooyal_oauth2.settings import oauth2_settings
from utils.clients.sms.fake.client import FakeClient
from utils.clients.sms.mobin_sms.client import MobinSMSClient
from utils.clients.sms.payam_sms.client import PayamSMSClient
from utils.clients.sms.sms import BaseSMSClient
from utils.models import BaseModel
logger = logging.getLogger(__name__)
import json
from datetime import datetime
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
class SMSClienChoises(TextChoices):
FAKE = "fake", _("fake")
PAYAM_SMS = 'payam_sms', _('payam_sms')
MOBIN_SMS = 'mobin_sms', _('mobin_sms')
class SMSPolicy(BaseModel):
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT,
related_name='+', null=True, blank=True, unique=True)
preferred = models.CharField(max_length=16, choices=SMSClienChoises.choices, default=SMSClienChoises.FAKE)
def __str__(self):
return self.get_preferred_display()
@staticmethod
def get_client() -> BaseSMSClient:
policy = SMSPolicy.objects.get_or_create(application=None)[0]
if policy.preferred == SMSClienChoises.MOBIN_SMS:
return MobinSMSClient()
elif policy.preferred == SMSClienChoises.PAYAM_SMS:
return PayamSMSClient()
else:
return FakeClient()
class ConfigValueChoices(models.TextChoices):
INT = 'INT', _('Integer')
FLOAT = 'FLOAT', _('Float')
BOOLEAN = 'BOOLEAN', _('Boolean')
STRING = 'STRING', _('String')
DATE = 'DATE', _('Date')
DATETIME = 'DATETIME', _('DateTime')
JSON = 'JSON', _('JSON')
class Config(BaseModel):
key = models.CharField(_('key'), max_length=255, unique=True, db_index=True)
value = models.TextField(_('value'), null=True, blank=True)
value_type = models.CharField(choices=ConfigValueChoices.choices, max_length=64)
description = models.TextField(_('description'), null=True, blank=True)
comment = models.TextField(_('comment'), null=True, blank=True)
def get_value(self):
return Config.get_value_of(self.key)
@staticmethod
def type_cast(value, value_type):
try:
if value_type == ConfigValueChoices.STRING.value:
value = str(value)
elif value_type == ConfigValueChoices.BOOLEAN.value:
value = value.lower() == 'true'
elif value_type == ConfigValueChoices.DATE.value:
value = datetime.fromisoformat(value)
elif value_type == ConfigValueChoices.DATETIME.value:
value = datetime.fromisoformat(value)
elif value_type == ConfigValueChoices.JSON.value:
value = json.loads(value)
elif value_type == ConfigValueChoices.INT.value:
value = int(value)
elif value_type == ConfigValueChoices.FLOAT.value:
value = float(value)
except Exception as e:
value = None
return value
@staticmethod
def get_value_of(key, default=None, casting_type=None):
# TODO: use cache
config, created = Config.objects.get_or_create(key=key, defaults={'value': default})
if config.value is None:
return default
value = Config.type_cast(config.value, casting_type or config.value_type)
return value

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

@ -0,0 +1,9 @@
from rest_framework import serializers
from .models import Config
class ConfigSerializer(serializers.ModelSerializer):
class Meta:
model = Config
fields = ['key', 'value', 'value_type']

View file

@ -1,7 +1,17 @@
from django.urls import path, include from django.urls import path, include
from .views import HomeView from rest_framework.routers import DefaultRouter
from .views import HomeView, TestIpView, HealthcheckView, StatusView
app_name = "core" app_name = "core"
router = DefaultRouter()
# router.register('api/config', ConfigViewSet, basename='configs')
urlpatterns = [ urlpatterns = [
path('', HomeView.as_view(), name='home'), path('', HomeView.as_view(), name='home'),
path('test-ip', TestIpView.as_view(), name='test-ip'),
path('status', StatusView.as_view(), name='status'),
path('healthcheck', HealthcheckView.as_view(), name='healthcheck'),
] ]

View file

@ -2,9 +2,82 @@ from django.contrib.auth.decorators import login_required
from django.shortcuts import render from django.shortcuts import render
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.contrib.sessions.backends.db import SessionStore
from django.contrib.sessions.models import Session
from rest_framework import mixins
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import GenericViewSet
from apps.core.decorators import service_availability
from apps.core.models import Config
from apps.core.serializers import ConfigSerializer
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
# Create your views here.
# @method_decorator(login_required, name='dispatch') # @method_decorator(login_required, name='dispatch')
class HomeView(TemplateView): class HomeView(TemplateView):
template_name = 'core/home.html' template_name = 'core/home.html'
# def get(self, request, *args, **kwargs):
# session = request.session
# print(type(session))
# print(session.session_key)
# return super(HomeView, self).get(request, *args, **kwargs)
class TestIpView(TemplateView):
template_name = 'core/home.html'
def get(self, request, *args, **kwargs):
print(request.headers)
headers_data = {'Host': 'accounts.gooyal.ir',
'X-Real-Ip': '212.23.216.131',
'X-Forwarded-For': '5.216.121.72, 212.23.216.131',
'X-Forwarded-Proto': 'http',
'Connection': 'close',
'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en,en-US;q=0.7,fa;q=0.3',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Cookie': 'csrftoken=xor4RVhDDuhQ5l4hf44iI3o2I0FJU8kJ; sessionid=3c0kju13bswh6ufu3mdnl1u38wi7d4b3',
'Upgrade-Insecure-Requests': '1',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'none',
'Sec-Fetch-User': '?1',
'Priority': 'u=0, i'}
return super(TestIpView, self).get(request, *args, **kwargs)
class ConfigViewSet(mixins.ListModelMixin, GenericViewSet):
queryset = Config.objects.all()
serializer_class = ConfigSerializer
permission_classes = [AllowAny]
class StatusView(APIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_alternate_scopes = {
"GET": [["accounts.status:get"]],
}
@service_availability('status')
def get(self, request, format=None):
data = {
"status": "ok",
}
return Response(data)
class HealthcheckView(APIView):
permission_classes = [AllowAny]
def get(self, request, format=None):
data = {
"is_healthy": True
}
return Response(data)

View file

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

View file

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

View file

@ -0,0 +1,99 @@
# Generated by Django 5.1.4 on 2025-11-20 09:05
import django.utils.timezone
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gooyal_oauth2', '0004_application_avatar'),
]
operations = [
migrations.AlterUniqueTogether(
name='refreshtoken',
unique_together=set(),
),
migrations.AddField(
model_name='application',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='application',
name='updated_at',
field=models.DateTimeField(auto_now=True, db_index=True),
),
migrations.AddField(
model_name='grant',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='grant',
name='updated_at',
field=models.DateTimeField(auto_now=True, db_index=True),
),
migrations.AddField(
model_name='refreshtoken',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='refreshtoken',
name='updated_at',
field=models.DateTimeField(auto_now=True, db_index=True),
),
migrations.AddField(
model_name='resource',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='resource',
name='updated_at',
field=models.DateTimeField(auto_now=True, db_index=True),
),
migrations.AddField(
model_name='scope',
name='created_at',
field=models.DateTimeField(auto_now_add=True, db_index=True, default=django.utils.timezone.now),
preserve_default=False,
),
migrations.AddField(
model_name='scope',
name='updated_at',
field=models.DateTimeField(auto_now=True, db_index=True),
),
migrations.AlterField(
model_name='application',
name='uuid',
field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True),
),
migrations.AlterField(
model_name='grant',
name='uuid',
field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True),
),
migrations.AlterField(
model_name='refreshtoken',
name='uuid',
field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True),
),
migrations.AlterField(
model_name='resource',
name='uuid',
field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True),
),
migrations.AlterField(
model_name='scope',
name='uuid',
field=models.UUIDField(db_index=True, default=uuid.uuid4, primary_key=True, serialize=False, unique=True),
),
]

View file

@ -0,0 +1,34 @@
# Generated by Django 6.0.2 on 2026-04-05 13:48
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gooyal_oauth2', '0005_alter_refreshtoken_unique_together_and_more'),
]
operations = [
migrations.RemoveField(
model_name='scope',
name='resource',
),
migrations.RemoveField(
model_name='application',
name='resource',
),
migrations.AddField(
model_name='application',
name='max_allowed_session',
field=models.PositiveIntegerField(default=0),
),
migrations.AlterField(
model_name='application',
name='authorization_grant_type',
field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('urn:ietf:params:oauth:grant-type:device_code', 'Device Code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=44),
),
migrations.DeleteModel(
name='Resource',
),
]

View file

@ -16,33 +16,19 @@ from django.conf import settings
from django.db import models, router, transaction from django.db import models, router, transaction
from django.utils import timezone from django.utils import timezone
from utils.models import BaseModel
from .settings import oauth2_settings from .settings import oauth2_settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Resource(models.Model): class Application(AbstractApplication, BaseModel):
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
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):
""" """
Application model for use with Django OAuth Toolkit that allows the scopes Application model for use with Django OAuth Toolkit that allows the scopes
available to an application to be restricted on a per-application basis. available to an application to be restricted on a per-application basis.
""" """
id=None id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
name = models.CharField(max_length=255, blank=False, unique=True) name = models.CharField(max_length=255, blank=False, unique=True)
user = models.ForeignKey( user = models.ForeignKey(
settings.AUTH_USER_MODEL, settings.AUTH_USER_MODEL,
@ -50,15 +36,10 @@ class Application(AbstractApplication):
on_delete=models.PROTECT on_delete=models.PROTECT
) )
allowed_scope = models.TextField(blank=True) allowed_scope = models.TextField(blank=True)
resource = models.OneToOneField(
Resource,
models.PROTECT,
blank=True, null=True,
help_text='The resource of application.',
related_name='application'
)
avatar = models.ImageField(upload_to='avatars', null=True, blank=True) avatar = models.ImageField(upload_to='avatars', null=True, blank=True)
max_allowed_session = models.PositiveIntegerField(default=0)
@property @property
def allowed_scopes(self): def allowed_scopes(self):
@ -74,14 +55,12 @@ class Application(AbstractApplication):
allowed_scopes = self.allowed_scopes allowed_scopes = self.allowed_scopes
return Scope.objects.filter(name__in=allowed_scopes).order_by('name') return Scope.objects.filter(name__in=allowed_scopes).order_by('name')
def allows_grant_type(self, *grant_types): def allows_grant_type(self, *grant_types):
# Assume, for this example, that self.authorization_grant_type is set to self.GRANT_AUTHORIZATION_CODE # Assume, for this example, that self.authorization_grant_type is set to self.GRANT_AUTHORIZATION_CODE
return bool(set([self.authorization_grant_type, self.GRANT_CLIENT_CREDENTIALS]) & set(grant_types)) return bool(set([self.authorization_grant_type, self.GRANT_CLIENT_CREDENTIALS]) & set(grant_types))
class Scope(models.Model): class Scope(BaseModel):
""" """
Django model for an OAuth scope. Django model for an OAuth scope.
""" """
@ -89,8 +68,6 @@ class Scope(models.Model):
# NOTE: This is not used to limit access to the scope in any way - we want the # NOTE: This is not used to limit access to the scope in any way - we want the
# scope to be available to other applications in order to request access # scope to be available to other applications in order to request access
# to the resource it protects! # to the resource it protects!
id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
application = models.ForeignKey( application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL, oauth2_settings.APPLICATION_MODEL,
models.CASCADE, models.CASCADE,
@ -101,13 +78,6 @@ class Scope(models.Model):
help_text='The application to which the scope belongs.', help_text='The application to which the scope belongs.',
related_name='scopes' related_name='scopes'
) )
resource = models.ForeignKey(
Resource,
models.PROTECT,
blank=True, null=True,
help_text='The resource of scope.',
related_name='scopes'
)
#: The name of the scope #: The name of the scope
name = models.CharField( name = models.CharField(
max_length=255, max_length=255,
@ -127,19 +97,10 @@ class Scope(models.Model):
@property @property
def final_name(self): def final_name(self):
args = [] return self.name
if self.resource:
args.append(self.resource.name)
args.append(self.name)
return '.'.join(args)
@property @property
def final_description(self): def final_description(self):
resource_name = self.resource and self.resource.name
if resource_name:
return f"{resource_name} -> {self.description}"
return self.description return self.description
@classmethod @classmethod
@ -177,17 +138,15 @@ class Scope(models.Model):
return True return True
class Grant(AbstractGrant): class Grant(AbstractGrant, BaseModel):
id = None id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
application = models.ForeignKey( application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, related_name='grants' oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, related_name='grants'
) )
class RefreshToken(AbstractRefreshToken): class RefreshToken(BaseModel, AbstractRefreshToken):
id = None id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
application = models.ForeignKey( application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, related_name='refresh_tokens') oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, related_name='refresh_tokens')
@ -215,7 +174,6 @@ class RefreshToken(AbstractRefreshToken):
self.save() self.save()
class AccessToken(AbstractAccessToken): class AccessToken(AbstractAccessToken):
id = None id = None
uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True)
@ -240,7 +198,7 @@ def clear_expired():
flat_queryset = queryset.values_list("pk", flat=True)[:CLEAR_EXPIRED_TOKENS_BATCH_SIZE] flat_queryset = queryset.values_list("pk", flat=True)[:CLEAR_EXPIRED_TOKENS_BATCH_SIZE]
batch_length = flat_queryset.count() batch_length = flat_queryset.count()
queryset.model.objects.filter(pk__in=list(flat_queryset)).delete() queryset.model.objects.filter(pk__in=list(flat_queryset)).delete()
logger.debug(f"{batch_length} tokens deleted, {current_no-batch_length} left") logger.debug(f"{batch_length} tokens deleted, {current_no - batch_length} left")
queryset = queryset.model.objects.filter(query) queryset = queryset.model.objects.filter(query)
time.sleep(CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL) time.sleep(CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL)
current_no = queryset.count() current_no = queryset.count()
@ -298,4 +256,3 @@ def clear_expired():
grants_deleted_no = batch_delete(grants, grants_query) grants_deleted_no = batch_delete(grants, grants_query)
logger.info("%s Expired grant tokens deleted", grants_deleted_no) logger.info("%s Expired grant tokens deleted", grants_deleted_no)

View file

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

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

@ -0,0 +1,212 @@
import base64
import json
import uuid
from datetime import timedelta
from unicodedata import category
from unittest.mock import patch
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from oauth2_provider.models import get_access_token_model, get_application_model
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from apps.core.models import Config
from apps.gooyal_oauth2.models import Scope
from apps.users.models import User
AccessToken = get_access_token_model()
Application = get_application_model()
def mock_notifications_push_user_success(user_uuid, title, message, priority=5, extras=None):
import uuid as sys_uuid
class Tmp():
uuid = sys_uuid.uuid4()
data = Tmp()
return data
class GooyalOAuth2Tests(APITestCase):
user_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005f')
access_token_1 = 'au4naVsdKCbKNOhnElPyXcrwSnqqFbm'
access_token_2 = 'vu4naVsdKCbKNOhnElPyXcrwSnqqFbm'
application_uuid = uuid.UUID('a14e8b86-8f4a-44d9-b29d-badceb47005f')
client_id = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u'
client_secret = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u'
visitor_uuid = uuid.UUID('b14e8b86-8f4a-44d9-b29d-badceb47005a')
expire_datetime = timezone.now() + timedelta(seconds=3600)
expire_datetime.isoformat()
client = APIClient()
def setUp(self):
from django.conf import settings
settings.SMS_SEND = False
self.notifications_push_user_success_patcher = patch('apps.users.models.User.notify',
mock_notifications_push_user_success)
self.notifications_push_user_success_patcher.start()
self.user_phone_number = '+989100000000'
self.user = User.objects.create(pk=self.user_uuid, phone_number=self.user_phone_number)
scope = Scope.objects.create(name='accounts.status:get', description='accounts.status:get')
self.application = Application.objects.create(
client_id=self.client_id,
client_secret=self.client_secret,
authorization_grant_type='password',
hash_client_secret=False,
uuid=self.application_uuid,
user_id=self.user_uuid,
max_allowed_session=1,
allowed_scope='accounts.status:get',
)
# self.sys_date_patcher = patch('simata_safte.models.get_sys_date', mock_get_sys_date)
# self.set_id_patcher = patch('simata_safte.models.set_id', mock_set_id)
# self.sign_pdf_patcher = patch('simata_safte.models.sign_pdf', mock_sign_pdf)
# self.check_pdf_signature_patcher = patch('simata_safte.models.check_pdf_signature', mock_check_pdf_signature)
def tearDown(self):
super().tearDown()
def _create_authorization_header(self, token):
return "Bearer {0}".format(token)
def test_authentication_allow(self):
access_token_1 = AccessToken.objects.create(
**{
"token": self.access_token_1,
"user": self.user,
# "client_id": self.client_id,
# "client_owner": owner,
"application_id": self.application_uuid,
"scope": 'accounts.status:get',
"expires": self.expire_datetime.isoformat(),
},
)
auth_1 = self._create_authorization_header(access_token_1.token)
response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1)
self.assertContains(response, 'status')
access_token_2 = AccessToken.objects.create(
**{
"token": self.access_token_2,
"user": self.user,
# "client_id": self.client_id,
# "client_owner": owner,
"application_id": self.application_uuid,
"scope": 'accounts.status:get',
"expires": self.expire_datetime.isoformat(),
},
)
auth_2 = self._create_authorization_header(access_token_2.token)
response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_1)
self.assertEqual(response.status_code, 200)
response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2)
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
self.application.max_allowed_session = 0
self.application.save()
self.application.refresh_from_db()
response = self.client.get(reverse("core:status"), HTTP_AUTHORIZATION=auth_2)
self.assertEqual(response.status_code, 200)
def basic_auth_string(self, username, password):
"""ساخت Basic Auth string"""
import base64
user_pass = f"{username}:{password}"
basic_credentials = base64.b64encode(user_pass.encode('utf-8')).decode('utf-8')
return basic_credentials
def login(self):
self.user.set_otp()
data = {
"grant_type": "password",
"username": self.user_phone_number,
"password": '77501',
"scope": 'accounts.status:get',
"auth_fields": 'phone_number:otp'
}
self.client.credentials(
HTTP_AUTHORIZATION='Basic ' + self.basic_auth_string(self.client_id, self.client_secret)
)
result = self.client.post(reverse("gooyal_oauth2:token"), data=data)
access_token = result.json()['access_token']
self.client.credentials(HTTP_AUTHORIZATION='Bearer ' + access_token)
self.assertEqual(result.status_code, 200)
return result
def test_loginByOTP_allOK_success(self):
print(self.login())
response = self.client.get(reverse("core:status"))
print(response.status_code)
def test_revoke_token(self):
self.user.set_otp()
data = {
"grant_type": "password",
"username": self.user_phone_number,
"password": '77501',
"scope": 'accounts.status:get',
"auth_fields": 'phone_number:otp'
}
self.client.credentials(
HTTP_AUTHORIZATION='Basic ' + self.basic_auth_string(self.client_id, self.client_secret)
)
result = self.client.post(reverse("gooyal_oauth2:token"), data=data)
access_token = result.json()['access_token']
revoke_data = {
"token": access_token,
}
result = self.client.post(reverse("gooyal_oauth2:revoke-token"), data=revoke_data)
self.assertEqual(result.status_code, 200)
self.assertEqual(AccessToken.objects.count(), 0)
self.user.refresh_from_db()
self.user.set_otp()
self.client.credentials(
HTTP_AUTHORIZATION='Basic ' + self.basic_auth_string(self.client_id, self.client_secret)
)
result = self.client.post(reverse("gooyal_oauth2:token"), data=data)
access_token = result.json()['access_token']
access_token_object = AccessToken.objects.first()
revoke_data = {
"token": access_token_object.pk,
}
result = self.client.post(reverse("gooyal_oauth2:revoke-token"), data=revoke_data)
self.assertEqual(result.status_code, 200)
self.assertEqual(AccessToken.objects.count(), 0)

View file

@ -0,0 +1,47 @@
from rest_framework.throttling import UserRateThrottle
from apps.gooyal_oauth2.models import AccessToken
def get_application(request):
try:
application = request.auth.application
except:
application = None
return application
class TokenLimitThrottle:
def allow_request(self, request, view):
application = get_application(request)
if application:
if hasattr(view, "max_allowed_session"):
max_allowed_session = view.max_allowed_session
else:
max_allowed_session = application.max_allowed_session
if max_allowed_session:
session_count = AccessToken.objects.filter(application=application, user=request.user).count()
if max_allowed_session >= session_count:
return True
else:
from utils.exceptions import Conflict
raise Conflict(code='max_allowed_session_reached')
active_tokens = AccessToken.objects.filter(
application=application, user=request.user
).order_by("created")[:max_allowed_session].values_list("token", flat=True)
if request.auth.token in active_tokens:
return True
else:
from utils.exceptions import Conflict
raise Conflict(code='max_allowed_session_reached')
else:
return True
return True
def wait(self):
pass

View file

@ -3,15 +3,17 @@ from django.urls import re_path, path
from oauth2_provider import views from oauth2_provider import views
from rest_framework import routers from rest_framework import routers
from .views.introspect import IntrospectTokenView, IntrospectApplicationView from .views.oauth_views import IntrospectTokenView, IntrospectApplicationView, TokenView, GooyalRevokeTokenView
from .views import apis as api_views from .views import apis as api_views
from .views import pages from .views import pages
app_name = "gooyal_oauth2" app_name = "gooyal_oauth2"
base_urlpatterns = [ base_urlpatterns = [
re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"),
re_path(r"^token/$", views.TokenView.as_view(), name="token"), 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/$", views.RevokeTokenView.as_view(), name="revoke-token"),
re_path(r"^introspect/$", IntrospectTokenView.as_view(), name="introspect"), re_path(r"^introspect/$", IntrospectTokenView.as_view(), name="introspect"),
re_path(r"^introspect_application/$", IntrospectApplicationView.as_view(), name="introspect-application"), re_path(r"^introspect_application/$", IntrospectApplicationView.as_view(), name="introspect-application"),

View file

@ -1,22 +1,34 @@
import base64 import base64
import binascii import binascii
import logging import logging
from copy import deepcopy
from datetime import datetime, timedelta from datetime import datetime, timedelta
from urllib.parse import unquote_plus from urllib.parse import unquote_plus
import requests import requests
# import service_clients # import service_clients
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.utils import timezone
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from oauth2_provider.models import get_access_token_model from oauth2_provider.exceptions import FatalClientError
from oauth2_provider.models import get_access_token_model, get_application_model, get_id_token_model, get_grant_model, \
get_refresh_token_model
from oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator from oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator
from requests import Session
from utils.exceptions import ServiceUnavailable
from .settings import oauth2_settings from .settings import oauth2_settings
from django.conf import settings from django.conf import settings
from django.db import router, transaction
log = logging.getLogger("oauth2_provider") log = logging.getLogger("oauth2_provider")
AccessTokenModel = get_access_token_model()
UserModel = get_user_model() UserModel = get_user_model()
Application = get_application_model()
AccessToken = get_access_token_model()
IDToken = get_id_token_model()
Grant = get_grant_model()
RefreshToken = get_refresh_token_model()
class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223
@ -77,3 +89,199 @@ class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223
def get_oidc_issuer_endpoint(self, request): def get_oidc_issuer_endpoint(self, request):
return oauth2_settings.oidc_issuer(request) return oauth2_settings.oidc_issuer(request)
def save_token(self, token, request, *args, **kwargs):
"""Persist the token with a token type specific method.
Currently, only save_bearer_token is supported.
:param token: A (Bearer) token dict.
:param request: OAuthlib request.
:type request: oauthlib.common.Request
"""
return self.save_bearer_token(token, request, *args, **kwargs)
def save_bearer_token(self, token, request, *args, **kwargs):
"""
Save access and refresh token.
Override _save_bearer_token and not this function when adding custom logic
for the storing of these token. This allows the transaction logic to be
separate from the token handling.
"""
# Use the AccessToken's database instead of making the assumption it is in 'default'.
with transaction.atomic(using=router.db_for_write(AccessToken)):
return self._save_bearer_token(token, request, *args, **kwargs)
def _save_bearer_token(self, token, request, *args, **kwargs):
"""
Save access and refresh token.
If refresh token is issued, remove or reuse old refresh token as in rfc:`6`.
@see: https://rfc-editor.org/rfc/rfc6749.html#section-6
"""
if "scope" not in token:
raise FatalClientError("Failed to renew access token: missing scope")
# expires_in is passed to Server on initialization
# custom server class can have logic to override this
expires = timezone.now() + timedelta(
seconds=token.get(
"expires_in",
oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS,
)
)
if request.grant_type == "client_credentials":
request.user = None
# This comes from OAuthLib:
# https://github.com/idan/oauthlib/blob/1.0.3/oauthlib/oauth2/rfc6749/tokens.py#L267
# Its value is either a new random code; or if we are reusing
# refresh tokens, then it is the same value that the request passed in
# (stored in `request.refresh_token`)
refresh_token_code = token.get("refresh_token", None)
if refresh_token_code:
# an instance of `RefreshToken` that matches the old refresh code.
# Set on the request in `validate_refresh_token`
refresh_token_instance = getattr(request, "refresh_token_instance", None)
# If we are to reuse tokens, and we can: do so
if (
not self.rotate_refresh_token(request)
and isinstance(refresh_token_instance, RefreshToken)
and refresh_token_instance.access_token
):
access_token = AccessToken.objects.select_for_update().get(
pk=refresh_token_instance.access_token.pk
)
access_token.user = request.user
access_token.scope = token["scope"]
access_token.expires = expires
access_token.token = token["access_token"]
access_token.application = request.client
access_token.save()
# else create fresh with access & refresh tokens
else:
# revoke existing tokens if possible to allow reuse of grant
if isinstance(refresh_token_instance, RefreshToken):
# First, to ensure we don't have concurrency issues, we refresh the refresh token
# from the db while acquiring a lock on it
# We also put it in the "request cache"
refresh_token_instance = RefreshToken.objects.select_for_update().get(
pk=refresh_token_instance.pk
)
request.refresh_token_instance = refresh_token_instance
previous_access_token = AccessToken.objects.filter(
source_refresh_token=refresh_token_instance
).first()
try:
refresh_token_instance.revoke()
except (AccessToken.DoesNotExist, RefreshToken.DoesNotExist):
pass
else:
setattr(request, "refresh_token_instance", None)
else:
previous_access_token = None
# If the refresh token has already been used to create an
# access token (ie it's within the grace period), return that
# access token
if not previous_access_token:
access_token = self._create_access_token(
expires,
request,
token,
source_refresh_token=refresh_token_instance,
)
self._create_refresh_token(
request, refresh_token_code, access_token, refresh_token_instance
)
else:
# make sure that the token data we're returning matches
# the existing token
token["access_token"] = previous_access_token.token
token["refresh_token"] = (
RefreshToken.objects.filter(access_token=previous_access_token).first().token
)
token["scope"] = previous_access_token.scope
# No refresh token should be created, just access token
else:
self._create_access_token(expires, request, token)
def _create_access_token(self, expires, request, token, source_refresh_token=None):
id_token = token.get("id_token", None)
if id_token:
id_token = self._load_id_token(id_token)
headers = {}
for header, value in dict(request.headers).items():
if type(value) in [str, bool, int, float, tuple, list]:
print(f'unserializable header: {header} -> {value}')
headers[header] = value
return AccessToken.objects.create(
user=request.user,
scope=token["scope"],
expires=expires,
token=token["access_token"],
id_token=id_token,
application=request.client,
source_refresh_token=source_refresh_token,
detail={'headers': headers},
)
# def _get_token_from_authentication_server
# def validate_bearer_token(self, token, scopes, request):
# result = super().validate_bearer_token(token, scopes, request)
# if result:
# application = request.client
# if hasattr(request, "max_allowed_session"):
# max_allowed_session = request.max_allowed_session
# else:
# max_allowed_session = application.max_allowed_session
#
# if max_allowed_session:
# session_count = AccessToken.objects.filter(application=application, user=request.user).count()
# if max_allowed_session >= session_count:
# return result
# else:
# active_tokens = AccessToken.objects.filter(
# application=application, user=request.user
# ).order_by("created")[:max_allowed_session].values_list("token", flat=True)
# if token in active_tokens:
# return result
# else:
# raise ServiceUnavailable(code='max_allowed_session_reached')
#
# else:
# return result
def revoke_token(self, token, token_type_hint, request, *args, **kwargs):
"""
Revoke an access or refresh token.
:param token: The token string.
:param token_type_hint: access_token or refresh_token.
:param request: The HTTP Request (oauthlib.common.Request)
"""
if token_type_hint not in ["access_token", "refresh_token"]:
token_type_hint = None
token_types = {
"access_token": AccessToken,
"refresh_token": RefreshToken,
}
token_type = token_types.get(token_type_hint, AccessToken)
try:
token_type.objects.get(pk=token).revoke()
except:
token_type.objects.get(token=token).revoke()

View file

@ -31,3 +31,9 @@ class ApplicationViewSet(ReadOnlyModelViewSet):
else: else:
raise Http404() raise Http404()
# @action(detail=True, permission_classes=[AllowAny])
# def tokens(self, request, pk=None):
# app: Application = self.get_object()

View file

@ -1,12 +1,16 @@
import calendar import calendar
from django.contrib.auth.decorators import login_not_required
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.http import JsonResponse from django.http import JsonResponse
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt from django.views.decorators.csrf import csrf_exempt
from django.utils.translation import gettext_lazy as _
from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.models import get_access_token_model, get_application_model
from oauth2_provider.signals import app_authorized
from oauth2_provider.views.generic import ClientProtectedScopedResourceView from oauth2_provider.views.generic import ClientProtectedScopedResourceView
from oauth2_provider.views.mixins import OAuthLibMixin
@method_decorator(csrf_exempt, name="dispatch") @method_decorator(csrf_exempt, name="dispatch")
@ -117,3 +121,63 @@ class IntrospectApplicationView(ClientProtectedScopedResourceView):
:return: :return:
""" """
return self.get_application_response(request.POST.get("client_id", None)) return self.get_application_response(request.POST.get("client_id", None))
import hashlib
import json
from django.http import HttpResponse
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import FormView, View
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class TokenView(OAuthLibMixin, View):
"""
Implements an endpoint to provide access tokens
The endpoint is used in the following flows:
* Authorization code
* Password
* Client credentials
"""
@method_decorator(sensitive_post_parameters("password", "client_secret"))
def post(self, request, *args, **kwargs):
url, headers, body, status = self.create_token_response(request)
content = json.loads(body)
for k,v in content.items():
_('Invalid credentials given.')
_('invalid_grant')
content[k] = _(v)
if status == 200:
access_token = json.loads(body).get("access_token")
if access_token is not None:
token_checksum = hashlib.sha256(access_token.encode("utf-8")).hexdigest()
token = get_access_token_model().objects.get(token_checksum=token_checksum)
app_authorized.send(sender=self, request=request, token=token)
response = HttpResponse(content=body, status=status)
for k, v in headers.items():
response[k] = v
return response
@method_decorator(csrf_exempt, name="dispatch")
@method_decorator(login_not_required, name="dispatch")
class GooyalRevokeTokenView(OAuthLibMixin, View):
"""
Implements an endpoint to revoke access or refresh tokens
"""
def post(self, request, *args, **kwargs):
url, headers, body, status = self.create_revocation_response(request)
response = HttpResponse(content=body or "", status=status)
for k, v in headers.items():
response[k] = v
return response

View file

@ -1,5 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import User from .models import User, InviteRequest
class UserAdmin(admin.ModelAdmin): class UserAdmin(admin.ModelAdmin):
@ -11,10 +11,7 @@ class UserAdmin(admin.ModelAdmin):
'first_name', 'first_name',
'last_name', 'last_name',
'username', 'username',
'email',
'phone_number', 'phone_number',
'password',
'otp',
'is_staff', 'is_staff',
'is_superuser', 'is_superuser',
'groups', 'groups',
@ -24,11 +21,28 @@ class UserAdmin(admin.ModelAdmin):
'last_update', 'last_update',
'otp_expire', 'otp_expire',
'otp_try', 'otp_try',
'balance',
] ]
readonly_fields = ['last_update', 'uuid'] readonly_fields = ['last_update', 'uuid']
list_display = ['pk', 'first_name', 'last_name', 'phone_number'] list_display = ['pk', 'date_joined', 'last_update']
search_fields = ['pk', 'first_name', 'last_name', 'phone_number'] search_fields = ['pk', 'first_name', 'last_name', 'phone_number']
class InviteRequestAdmin(admin.ModelAdmin):
fields = [
'uuid',
'inviter',
'mobile_number',
'application',
'created_at',
'updated_at',
]
readonly_fields = ['uuid', 'created_at', 'updated_at']
list_display = ['uuid', 'mobile_number', 'inviter', 'application', 'created_at']
list_filter = ['created_at', 'application']
search_fields = ['uuid', 'mobile_number', 'inviter__uuid', 'inviter__phone_number']
raw_id_fields = ['inviter', 'application']
date_hierarchy = 'created_at'
admin.site.register(User, UserAdmin) admin.site.register(User, UserAdmin)
admin.site.register(InviteRequest, InviteRequestAdmin)

View file

@ -2,7 +2,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
MAX_OTP_TRY = 3 MAX_OTP_TRY = 3
DEVELOPMENT_PHONE_NUMBERS = ['+989999999999', '+989999999998'] DEVELOPMENT_PHONE_NUMBERS = []
MAX_OTP_VALID_DURATION = 120 MAX_OTP_VALID_DURATION = 120
class GenderChoices(models.TextChoices): class GenderChoices(models.TextChoices):

View file

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

View file

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

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-11-18 11:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_alter_user_avatar'),
]
operations = [
migrations.AlterField(
model_name='user',
name='otp',
field=models.CharField(blank=True, max_length=128, null=True, verbose_name='otp'),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 6.0.2 on 2026-03-16 12:23
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0007_alter_user_otp'),
]
operations = [
migrations.AlterModelOptions(
name='user',
options={'ordering': ['-date_joined'], 'verbose_name': 'user', 'verbose_name_plural': 'users'},
),
]

View file

@ -0,0 +1,33 @@
# Generated by Django 5.1.4 on 2026-07-22 13:45
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0008_alter_user_options'),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
]
operations = [
migrations.CreateModel(
name='InviteRequest',
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)),
('mobile_number', models.CharField(db_index=True, max_length=30, verbose_name='mobile number')),
('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='invite_requests', to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invite_requests', to=settings.AUTH_USER_MODEL, verbose_name='inviter')),
],
options={
'verbose_name': 'invite request',
'verbose_name_plural': 'invite requests',
'ordering': ['-created_at'],
},
),
]

View file

@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2026-07-25
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0009_inviterequest'),
]
operations = [
migrations.AlterField(
model_name='inviterequest',
name='mobile_number',
field=models.CharField(db_index=True, max_length=30, unique=True, verbose_name='mobile number'),
),
]

View file

@ -1,3 +1,4 @@
import logging
import random import random
import string import string
from datetime import timedelta from datetime import timedelta
@ -5,6 +6,7 @@ from datetime import timedelta
# import service_clients # import service_clients
from django.conf import settings from django.conf import settings
from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.base_user import BaseUserManager
from django.contrib.auth.hashers import make_password, check_password
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.contrib.auth.validators import UnicodeUsernameValidator from django.contrib.auth.validators import UnicodeUsernameValidator
from django.db import models from django.db import models
@ -13,13 +15,16 @@ from django.utils.translation import gettext_lazy as _
import uuid import uuid
from django_minio_backend import MinioBackend, iso_date_prefix from django_minio_backend import MinioBackend, iso_date_prefix
from apps.core.models import SMSPolicy
from utils.clients.payam_sms import send_sms
from .provinces_and_cities import state from .provinces_and_cities import state
# from .tasks import send_notification # from .tasks import send_notification
from apps.users.constans import MAX_OTP_TRY, DEVELOPMENT_PHONE_NUMBERS, GenderChoices, EducationDegreeChoices, \ from apps.users.constans import MAX_OTP_TRY, DEVELOPMENT_PHONE_NUMBERS, GenderChoices, EducationDegreeChoices, \
MAX_OTP_VALID_DURATION MAX_OTP_VALID_DURATION
from oauth2_provider import settings as oauth2_settings
from utils.models import BaseModel
logger = logging.getLogger(__name__)
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
@ -34,7 +39,7 @@ class UserManager(BaseUserManager):
# TODO: validate phone number # TODO: validate phone number
user = self.model(phone_number=phone_number, username=username, email=email, **extra_fields) user = self.model(phone_number=phone_number, username=username, email=email, **extra_fields)
user.set_password(password) user.set_password(password)
user.set_otp() # user.set_otp()
user.date_joined = timezone.now() user.date_joined = timezone.now()
user.save(using=self._db) user.save(using=self._db)
@ -113,7 +118,7 @@ class User(AbstractUser):
null=True null=True
) )
phone_number = models.CharField(_('phone number'), max_length=30, blank=True, null=True, unique=True) phone_number = models.CharField(_('phone number'), max_length=30, blank=True, null=True, unique=True)
otp = models.CharField(_('otp'), max_length=6, blank=True, null=True) otp = models.CharField(_('otp'), max_length=128, blank=True, null=True)
otp_expire = models.DateTimeField(_('otp expire'), blank=True, null=True) otp_expire = models.DateTimeField(_('otp expire'), blank=True, null=True)
otp_try = models.IntegerField(_('otp try'), blank=True, null=True, default=0) otp_try = models.IntegerField(_('otp try'), blank=True, null=True, default=0)
@ -139,6 +144,12 @@ class User(AbstractUser):
objects = UserManager() objects = UserManager()
class Meta:
verbose_name = _("user")
verbose_name_plural = _("users")
ordering = ['-date_joined']
def get_display_name(self): def get_display_name(self):
return str(self.name or self.get_full_name() or self.username or self.email or self.phone_number or self.pk) return str(self.name or self.get_full_name() or self.username or self.email or self.phone_number or self.pk)
@ -151,16 +162,17 @@ class User(AbstractUser):
return state.get_province_by_id(self.province) return state.get_province_by_id(self.province)
def set_otp(self): def set_otp(self):
self.otp = '77501' if not settings.SMS_SEND:
# if settings.DEBUG or self.phone_number in DEVELOPMENT_PHONE_NUMBERS: self._otp = '77501'
# self.otp = '77501' elif self.phone_number in settings.TEST_PHONENUMBERS:
# self._otp = '88555'
# else: else:
# self.otp = ''.join(random.choice('0123456789') for _ in range(5)) self._otp = ''.join(random.choice('0123456789') for _ in range(5))
# self.otp = ''.join(random.choice('0123456789') for _ in range(5))
self.otp = make_password(self._otp)
self.otp_expire = timezone.now() + timedelta(seconds=MAX_OTP_VALID_DURATION) self.otp_expire = timezone.now() + timedelta(seconds=MAX_OTP_VALID_DURATION)
self.otp_try = 0 self.otp_try = 0
self.save()
def set_ott(self): def set_ott(self):
self.ott = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(32)) self.ott = ''.join(random.choice(string.ascii_letters + string.digits) for _ in range(32))
@ -168,25 +180,33 @@ class User(AbstractUser):
self.ott_expire = timezone.now() + timedelta(seconds=MAX_OTP_VALID_DURATION) self.ott_expire = timezone.now() + timedelta(seconds=MAX_OTP_VALID_DURATION)
def otp_is_valid(self): def otp_is_valid(self):
return bool(self.otp and timezone.now() <= self.otp_expire) return bool(self.otp and self.otp_expire and timezone.now() <= self.otp_expire)
def ott_is_valid(self): def ott_is_valid(self):
return bool(self.ott and timezone.now() <= self.ott_expire) return bool(self.ott and self.ott_expire and timezone.now() <= self.ott_expire)
def check_otp(self, otp): def check_otp(self, otp):
if self.otp_try <= MAX_OTP_TRY: if self.otp_try <= MAX_OTP_TRY:
result = otp and self.otp == otp and self.otp_is_valid() try:
result = otp and check_password(otp, self.otp) and self.otp_is_valid()
except Exception as e:
result = False
if result: if result:
self.otp = None self.otp = None
self.date_joined = timezone.now() self.otp_expire = None
if not self.date_joined:
self.date_joined = timezone.now()
else: else:
self.otp_try += 1 self.otp_try += 1
self.save()
else: else:
self.otp = None
result = False result = False
self.otp = None
self.otp_expire = None
self.save()
self.save()
return result return result
def check_ott(self, ott: str): def check_ott(self, ott: str):
@ -221,18 +241,22 @@ class User(AbstractUser):
return result return result
def send_otp(self): def send_otp(self):
print(self.otp) print(self._otp)
self.notify(body=str(self.otp), title='OTP') self.notify(body=str(self._otp), title='OTP')
def notify(self, body, title=None, notification_type='sms'): def notify(self, body, title=None, notification_type='sms'):
message = ("وینسو" message = ("وینسو"
"\n" "\n"
f"رمزیکبارمصرف: {body}" f"رمز یکبار مصرف: {body}"
# "\n" # "\n"
# # f"code is: {body}\n" # # f"code is: {body}\n"
# f"{settings.SMS_OTP_SIGNITURE}" # f"{settings.SMS_OTP_SIGNITURE}"
) )
send_sms(self.phone_number.strip('+'), 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 return
# TODO: enable celery # TODO: enable celery
send_notification( send_notification(
@ -245,3 +269,28 @@ class User(AbstractUser):
def __str__(self): def __str__(self):
return self.name or self.get_full_name() or self.username or self.phone_number or self.email or _('no name') return self.name or self.get_full_name() or self.username or self.phone_number or self.email or _('no name')
class InviteRequest(BaseModel):
inviter = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name='invite_requests',
verbose_name=_('inviter'),
)
mobile_number = models.CharField(_('mobile number'), unique=True, max_length=30, db_index=True)
application = models.ForeignKey(
oauth2_settings.APPLICATION_MODEL,
on_delete=models.PROTECT,
related_name='invite_requests',
null=True,
blank=True
)
class Meta:
verbose_name = _('invite request')
verbose_name_plural = _('invite requests')
ordering = ['-created_at']
def __str__(self):
return f'{self.mobile_number} -> {self.inviter_id}'

View file

@ -1,9 +1,13 @@
from django.core.validators import RegexValidator from django.core.validators import RegexValidator
from oauth2_provider.models import get_access_token_model
from rest_framework import serializers from rest_framework import serializers
from apps.users.models import User from apps.users.models import User, InviteRequest
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from unidecode import unidecode
from utils.exceptions import UnprocessableEntity, Conflict
phone_number_validator = RegexValidator(regex=r'^\+98[1-9]\d{9}$', message=_( phone_number_validator = RegexValidator(regex=r'^\+98[1-9]\d{9}$', message=_(
"Phone number must be entered in the format: '+989999999999'." "Phone number must be entered in the format: '+989999999999'."
@ -59,6 +63,10 @@ class RequestOTPSerializer(serializers.ModelSerializer):
read_only_fields = ['otp_expire', 'ttl'] read_only_fields = ['otp_expire', 'ttl']
def validate(self, attrs):
attrs['phone_number'] = unidecode(attrs['phone_number'])
return attrs
def save(self, **kwargs): def save(self, **kwargs):
# TODO: move it to query set # TODO: move it to query set
validated_data = self.validated_data validated_data = self.validated_data
@ -69,9 +77,7 @@ class RequestOTPSerializer(serializers.ModelSerializer):
if not user.otp_is_valid(): if not user.otp_is_valid():
user.set_otp() user.set_otp()
user.save() user.send_otp()
user.send_otp()
self.instance = user self.instance = user
return user return user
@ -126,3 +132,66 @@ class ChangePasswordSerializer(serializers.Serializer):
old_password = serializers.CharField(required=True) old_password = serializers.CharField(required=True)
old_password_field = serializers.CharField(default='password') old_password_field = serializers.CharField(default='password')
new_password = serializers.CharField(required=True) new_password = serializers.CharField(required=True)
class InviteRequestSerializer(serializers.ModelSerializer):
invite_code = serializers.UUIDField(write_only=True)
class Meta:
model = InviteRequest
fields = ('uuid', 'invite_code', 'mobile_number', 'created_at')
read_only_fields = ('uuid', 'created_at')
def validate_invite_code(self, value):
if not User.objects.filter(uuid=value).exists():
raise serializers.ValidationError(_('Invalid invite code.'))
return value
def validate_mobile_number(self, value):
value = unidecode(value)
phone_number_validator(value)
if User.objects.filter(phone_number=value).exists():
raise Conflict(
detail=_('This mobile number is already registered as a user.'),
code='user_already_exists',
)
if InviteRequest.objects.filter(mobile_number=value).exists():
raise Conflict(
detail=_('An invite request for this mobile number already exists.'),
code='invite_request_already_exists',
)
return value
def create(self, validated_data):
invite_code = validated_data.pop('invite_code')
application = self.context['request'].auth.application
return InviteRequest.objects.create(
inviter_id=invite_code,
application=application,
**validated_data,
)
AccessToken = get_access_token_model()
class SessionSerializer(serializers.ModelSerializer):
is_current = serializers.SerializerMethodField()
def get_is_current(self, obj):
request = self.context.get('request')
token = request and request.auth and request.auth.token
if token == obj.token:
result = True
else:
result = False
return result
class Meta:
model = AccessToken
fields = ('uuid', 'created', "detail", "is_current")
read_only_fields = ['uuid', 'created', "detail", "is_current" ]

View file

@ -1,3 +1,271 @@
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, InviteRequest
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)
class InviteRequestTests(APITestCase):
user_uuid = uuid.UUID('c14e8b86-8f4a-44d9-b29d-badceb47005f')
inviter_uuid = uuid.UUID('d14e8b86-8f4a-44d9-b29d-badceb47005f')
application_uuid = uuid.UUID('e14e8b86-8f4a-44d9-b29d-badceb47005f')
access_token = 'inviteAccessToken123456789012345678901'
client_id = 'inviteClientId1234567890123456789012'
client_secret = 'inviteClientSecret123456789012345678'
mobile_number = '+989120000000'
expire_datetime = timezone.now() + timedelta(seconds=3600)
client = APIClient()
def setUp(self):
self.user = User.objects.create(pk=self.user_uuid, phone_number='+989100000001')
self.inviter = User.objects.create(pk=self.inviter_uuid, phone_number='+989100000002')
Scope.objects.create(name='accounts.invite:submit', description='accounts.invite:submit')
self.application = Application.objects.create(
name='invite-test-app',
client_id=self.client_id,
client_secret=self.client_secret,
client_type=Application.CLIENT_CONFIDENTIAL,
authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS,
hash_client_secret=False,
uuid=self.application_uuid,
user_id=self.user_uuid,
allowed_scope='accounts.invite:submit',
)
AccessToken.objects.create(
token=self.access_token,
application_id=self.application_uuid,
scope='accounts.invite:submit',
expires=self.expire_datetime,
)
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {self.access_token}')
def test_create_invite_request_success(self):
response = self.client.post(
reverse('users:invite_request_api'),
data={
'invite_code': str(self.inviter_uuid),
'mobile_number': self.mobile_number,
},
format='json',
)
self.assertEqual(response.status_code, 201)
self.assertEqual(InviteRequest.objects.count(), 1)
invite_request = InviteRequest.objects.get()
self.assertEqual(invite_request.inviter_id, self.inviter_uuid)
self.assertEqual(invite_request.mobile_number, self.mobile_number)
self.assertEqual(invite_request.application_id, self.application_uuid)
def test_create_invite_request_invalid_invite_code(self):
response = self.client.post(
reverse('users:invite_request_api'),
data={
'invite_code': str(uuid.uuid4()),
'mobile_number': self.mobile_number,
},
format='json',
)
self.assertEqual(response.status_code, 400)
self.assertEqual(InviteRequest.objects.count(), 0)
def test_create_invite_request_existing_user(self):
response = self.client.post(
reverse('users:invite_request_api'),
data={
'invite_code': str(self.inviter_uuid),
'mobile_number': self.inviter.phone_number,
},
format='json',
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()['details']['error'], 'user_already_exists')
self.assertEqual(InviteRequest.objects.count(), 0)
def test_create_invite_request_duplicate_mobile_number(self):
InviteRequest.objects.create(
inviter=self.inviter,
mobile_number=self.mobile_number,
application=self.application,
)
response = self.client.post(
reverse('users:invite_request_api'),
data={
'invite_code': str(self.inviter_uuid),
'mobile_number': self.mobile_number,
},
format='json',
)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()['details']['error'], 'invite_request_already_exists')
self.assertEqual(InviteRequest.objects.count(), 1)
def test_create_invite_request_missing_scope(self):
self.application.allowed_scope = ''
self.application.save(update_fields=['allowed_scope'])
response = self.client.post(
reverse('users:invite_request_api'),
data={
'invite_code': str(self.inviter_uuid),
'mobile_number': self.mobile_number,
},
format='json',
)
self.assertEqual(response.status_code, 403)
self.assertEqual(InviteRequest.objects.count(), 0)
# Create your tests here.

View file

@ -2,17 +2,18 @@ from django.urls import path
from django.contrib.auth.views import LogoutView from django.contrib.auth.views import LogoutView
from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \ from .views import UserListView, UserPublicRetrieveView, AccountView, RequestOTPView, ChangePasswordView, \
OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \ OTPLoginView, ProfileDetailView, ProfileUpdateView, RequestOTTView, UserCurrentAvatarUrlView, UserInquiryView, \
UserDetailedRetrieveView UserDetailedRetrieveView, UserSessionListView, UserProfileView, InviteRequestView
app_name = "users" app_name = "users"
urlpatterns = [ urlpatterns = [
path('login/', OTPLoginView.as_view(), name='login'), path('login/', OTPLoginView.as_view(), name='login'),
path('logout/', LogoutView.as_view(), name='logout'), path('logout/', LogoutView.as_view(), name='logout'),
path('account/', ProfileDetailView.as_view(), name='account_detail'), path('account/', ProfileDetailView.as_view(), name='account_detail'),
path('account/update/', ProfileUpdateView.as_view(), name='account_update'), path('account/update/', ProfileUpdateView.as_view(), name='account_update'),
path('api/account/', AccountView.as_view(), name='account_api'), path('api/account/', AccountView.as_view(), name='account_api'),
path('api/users/', UserListView.as_view(), name='user_list_api'), path('api/users/all', UserListView.as_view(), name='user_list_api'),
path('api/users/', UserProfileView.as_view(), name='user_profile_list_api'),
path('api/sessions/', UserSessionListView.as_view(), name='user_sessions_api'),
path('api/users/<uuid>/', UserPublicRetrieveView.as_view(), name='user_public_retrieve_api'), path('api/users/<uuid>/', UserPublicRetrieveView.as_view(), name='user_public_retrieve_api'),
path('api/users/<uuid>/avatar', UserCurrentAvatarUrlView.as_view(), name='user_avatar_api'), path('api/users/<uuid>/avatar', UserCurrentAvatarUrlView.as_view(), name='user_avatar_api'),
path('api/users/<uuid>/details', UserDetailedRetrieveView.as_view(), name='user_detailed_retrieve_api'), path('api/users/<uuid>/details', UserDetailedRetrieveView.as_view(), name='user_detailed_retrieve_api'),
@ -20,4 +21,6 @@ urlpatterns = [
path('api/request_ott/', RequestOTTView.as_view(), name='request_ott_api'), path('api/request_ott/', RequestOTTView.as_view(), name='request_ott_api'),
path('api/change_password/', ChangePasswordView.as_view(), name='change_password_api'), path('api/change_password/', ChangePasswordView.as_view(), name='change_password_api'),
path('api/inquiry/', UserInquiryView.as_view(), name='user_inquiry_api'), path('api/inquiry/', UserInquiryView.as_view(), name='user_inquiry_api'),
path('api/invite_requests/', InviteRequestView.as_view(), name='invite_request_api'),
] ]

View file

@ -17,26 +17,35 @@ from rest_framework.views import APIView
from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements from apps.gooyal_oauth2.rest_framework import IsAuthenticatedOrTokenMatchesOASRequirements
from apps.users.filters import UserFilter from apps.users.filters import UserFilter
from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm from apps.users.forms import OTPAuthenticationForm, ProfileUpdateForm
from apps.users.models import User from apps.users.models import User, InviteRequest
from apps.users.provinces_and_cities import State
from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \ from apps.users.serializers import PublicUserSerializer, AccountSerializer, RequestOTPSerializer, RequestOTTSerializer, \
ChangePasswordSerializer, UserInquirySerializer ChangePasswordSerializer, UserInquirySerializer, SessionSerializer, InviteRequestSerializer
from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle from utils.throttles import RequestOTPDayRateThrottle, RequestOTPMinRateThrottle, NumberedRequestOTPDayRateThrottle, NumberedRequestOTPMinRateThrottle
UserModel = get_user_model() UserModel = get_user_model()
class UserListView(generics.ListAPIView): class BaseUserListView(generics.ListAPIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
queryset = User.objects.all() queryset = User.objects.all()
serializer_class = PublicUserSerializer serializer_class = PublicUserSerializer
filter_backends = (DjangoFilterBackend,) filter_backends = (DjangoFilterBackend,)
filterset_class = UserFilter
# Make filterset_class abstract - must be defined in subclasses
filterset_class = None
class UserProfileView(BaseUserListView):
filterset_class = UserFilter
required_alternate_scopes = { required_alternate_scopes = {
"GET": [["accounts.profile:list"]], "GET": [["accounts.profile:list"]],
} }
class UserListView(BaseUserListView):
# filterset_class = UserFilter
required_alternate_scopes = {
"GET": [["accounts.users:list"]],
}
class UserPublicRetrieveView(generics.RetrieveAPIView): class UserPublicRetrieveView(generics.RetrieveAPIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements] permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
@ -90,7 +99,10 @@ class RequestOTPView(generics.CreateAPIView):
permission_classes = [] permission_classes = []
serializer_class = RequestOTPSerializer serializer_class = RequestOTPSerializer
required_scopes = [] required_scopes = []
throttle_classes = [RequestOTPMinRateThrottle, RequestOTPDayRateThrottle] throttle_classes = [RequestOTPMinRateThrottle,
RequestOTPDayRateThrottle,
NumberedRequestOTPDayRateThrottle,
NumberedRequestOTPMinRateThrottle]
class RequestOTTView(generics.CreateAPIView): class RequestOTTView(generics.CreateAPIView):
permission_classes = [IsAuthenticatedOrTokenHasScope] permission_classes = [IsAuthenticatedOrTokenHasScope]
@ -115,6 +127,15 @@ class UserInquiryView(generics.CreateAPIView):
required_scopes = ['accounts.profile:inquiry'] required_scopes = ['accounts.profile:inquiry']
class InviteRequestView(generics.CreateAPIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
required_scopes = ['accounts.invite:submit']
required_alternate_scopes ={
"POST": [["accounts.invite:submit"]],
}
serializer_class = InviteRequestSerializer
queryset = InviteRequest.objects.all()
class ChangePasswordView(generics.UpdateAPIView): class ChangePasswordView(generics.UpdateAPIView):
permission_classes = [IsAuthenticatedOrTokenHasScope] permission_classes = [IsAuthenticatedOrTokenHasScope]
serializer_class = ChangePasswordSerializer serializer_class = ChangePasswordSerializer
@ -180,3 +201,20 @@ class ProfileUpdateView(UpdateView):
def form_valid(self, form): def form_valid(self, form):
return super().form_valid(form) return super().form_valid(form)
class UserSessionListView(generics.ListAPIView):
permission_classes = [IsAuthenticatedOrTokenMatchesOASRequirements]
serializer_class = SessionSerializer
filter_backends = (DjangoFilterBackend,)
max_allowed_session = 0
required_alternate_scopes = {
"GET": [["accounts.account:retrieve"]],
}
def get_queryset(self):
from apps.gooyal_oauth2.models import AccessToken
return AccessToken.objects.filter(user=self.request.user).all()

View file

@ -3,10 +3,10 @@ import time
import requests import requests
OAUTH_CLIENT_ID = 'xrFXKf53jrxVOygbLEoqrtOlUwBP00jIQ4zVpzc6' OAUTH_CLIENT_ID = '4INGOCMoulE0fNY1SQlTbPtsWqqxGj2DdqjADq6u'
OAUTH_CLIENT_SECRET = 'qelUdRCdEEalVdko5aHRojxsC3CaL29yjwxmc6FeWs39SeVSb6aM9mNrYQixMJNTYQK7s2wffwPW94JPPbP6jTdd9dSf1mlqYcr6hW2COuayFUk4jP33OWu4taUYP2F5' OAUTH_CLIENT_SECRET = 'KPc4dMSztNwAIB3vii3geXrzC1mKUIsAztz3t2ylC3HlrgJudJWhdrtoY6XeRJIuadTAREYYsXk9XtFHUDfPVcJvyfHMpNkz2VvghwrijhVprok0VYV7XrOirJ5nFUxD'
API_URI = 'https://accounts.gooyal.com' API_URI = 'https://accounts.gooyal.ir'
# API_URI = 'http://127.0.0.1:8000' # API_URI = 'http://127.0.0.1:8000'
@ -27,7 +27,7 @@ class ApiClient():
"grant_type": "password", "grant_type": "password",
"username": phone_number, "username": phone_number,
"password": password, "password": password,
"scope": 'accounts.account:request_ott accounts.account:change_password accounts.profile:inquiry accounts.account:update accounts.account:retrieve accounts.profile:retrieve accounts.profile:list introspection wallet.wallet:get_balance', "scope": 'introspection',
"auth_fields": 'phone_number:otp' "auth_fields": 'phone_number:otp'
} }
auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET)
@ -51,7 +51,8 @@ class ApiClient():
"grant_type": "password", "grant_type": "password",
"username": phone_number, "username": phone_number,
"password": token, "password": token,
"scope": 'introspection education.course:retrieve education.course:submit superapp.applications:list accounts.account:request_ott accounts.profile:list accounts.profile:retrieve accounts.account:retrieve accounts.account:update accounts.profile:inquiry accounts.account:change_password wallet.transaction:list wallet.invoice:create wallet.transaction:retrieve wallet.invoice:pay wallet.invoice:receipt wallet.deposit:submit wallet.deposit:verify wallet.withdraw:submit wallet.withdraw:verify ', # "scope": 'introspection education.course:retrieve education.course:submit superapp.applications:list accounts.account:request_ott accounts.profile:list accounts.profile:retrieve accounts.account:retrieve accounts.account:update accounts.profile:inquiry accounts.account:change_password wallet.transaction:list wallet.invoice:create wallet.transaction:retrieve wallet.invoice:pay wallet.invoice:receipt wallet.deposit:submit wallet.deposit:verify wallet.withdraw:submit wallet.withdraw:verify ',
"scope": 'introspection',
"auth_fields": 'phone_number:ott' "auth_fields": 'phone_number:ott'
} }
auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET) auth = (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET)
@ -156,6 +157,7 @@ class ApiClient():
} }
url = f'{API_URI}/{path}' url = f'{API_URI}/{path}'
response = self._request(url, method='PUT', data=data) response = self._request(url, method='PUT', data=data)
print(response)
return response and 'code' in response, response return response and 'code' in response, response
def introspect_account(self, token): def introspect_account(self, token):
@ -185,19 +187,29 @@ class ApiClient():
return self._request(url=url) return self._request(url=url)
def get_wallet_balance(self): def get_application_preferences(self):
url = 'http://127.0.0.1:8000/wallet/api/wallet/default/balance' url = 'https://preferences.gooyal.com/data/application/'
return self._request(url=url) return self._request(url=url)
auth_data = {'access_token': 'DJ1mycH9r5FCwaNgDA9nB9uU3KWt9m', 'expires_in': 36000, 'token_type': 'Bearer', 'scope': 'accounts.account:change_password accounts.account:update accounts.account:retrieve wallet.wallet:get_balance billboard_merchant.billboard:retrieve billboard_merchant.billboard:update billboard_merchant.billboard:create wallet.user:transaction_list data_crud.data:retrieve data_crud.data:update data_crud.data:delete', 'refresh_token': 'VF4P11tfcnGv9tc2u0qH6035OW5qU4'}
client = ApiClient('+989106853582') client = ApiClient('+989106853582')
# client.auth_data = auth_data
# print(client.login_as_client_credentials()) # print(client.login_as_client_credentials())
print(client.request_otp()) print(client.request_otp())
print(client.otp_login('12345')) import time
print(client.introspect_account('V3LUQ9OryHOy6q5iWwLBRAtRBm80ex')) start = time.time()
print(client.get_wallet_balance()) print(client.otp_login('77502'))
# end = time.time()
# print(end - start)
# print(client.get_application_preferences())
# exit()
# print(client.introspect_account('V3LUQ9OryHOy6q5iWwLBRAtRBm80ex'))
# print(client.get_wallet_balance())
# ott = client.get_application_token()[1]['ott'] # ott = client.get_application_token()[1]['ott']
# print(client.ott_login(ott)) # print(client.ott_login(ott))
# print(ott) # print(ott)
client.update_account('test')

Binary file not shown.

View file

@ -0,0 +1,346 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 10:43+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#: accounts/settings.py:222
msgid "English"
msgstr ""
#: accounts/settings.py:223
msgid "Persian"
msgstr ""
#: accounts/settings.py:224
msgid "Arabic"
msgstr ""
#: accounts/settings.py:225
msgid "Turkish"
msgstr ""
#: apps/gooyal_oauth2/views/introspect.py:153
msgid "Invalid credentials given."
msgstr ""
#: apps/gooyal_oauth2/views/introspect.py:154
msgid "invalid_grant"
msgstr ""
#: apps/users/constans.py:9
msgid "male"
msgstr ""
#: apps/users/constans.py:10
msgid "female"
msgstr ""
#: apps/users/constans.py:14
msgid "Primary school"
msgstr ""
#: apps/users/constans.py:15
msgid "Middle school"
msgstr ""
#: apps/users/constans.py:16
msgid "High school"
msgstr ""
#: apps/users/constans.py:17
msgid "Associate degree"
msgstr ""
#: apps/users/constans.py:18
msgid "Bachelors degree"
msgstr ""
#: apps/users/constans.py:19
msgid "Masters degree"
msgstr ""
#: apps/users/constans.py:20
msgid "Doctorate"
msgstr ""
#: apps/users/forms.py:16 apps/users/models.py:117
msgid "otp"
msgstr ""
#: apps/users/forms.py:23
#, python-format
msgid ""
"Please enter a correct %(phone_number)s and otp. Note that both fields may "
"be case-sensitive."
msgstr ""
#: apps/users/forms.py:26
msgid "This account is inactive."
msgstr ""
#: apps/users/models.py:72
msgid "name"
msgstr ""
#: apps/users/models.py:73
msgid "first name"
msgstr ""
#: apps/users/models.py:74
msgid "last name"
msgstr ""
#: apps/users/models.py:75
msgid "email address"
msgstr ""
#: apps/users/models.py:84
msgid "Province"
msgstr ""
#: apps/users/models.py:85
msgid "City"
msgstr ""
#: apps/users/models.py:86
msgid "postal_code"
msgstr ""
#: apps/users/models.py:87
msgid "address"
msgstr ""
#: apps/users/models.py:90
msgid "staff status"
msgstr ""
#: apps/users/models.py:92
msgid "Designates whether the user can log into this admin site."
msgstr ""
#: apps/users/models.py:95
msgid "active"
msgstr ""
#: apps/users/models.py:98
msgid ""
"Designates whether this user should be treated as active. Unselect this "
"instead of deleting accounts."
msgstr ""
#: apps/users/models.py:102
msgid "password"
msgstr ""
#: apps/users/models.py:105
msgid "username"
msgstr ""
#: apps/users/models.py:108
msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
msgstr ""
#: apps/users/models.py:111
msgid "A user with that username already exists."
msgstr ""
#: apps/users/models.py:116
msgid "phone number"
msgstr ""
#: apps/users/models.py:118 apps/users/models.py:122 apps/users/models.py:124
msgid "otp expire"
msgstr ""
#: apps/users/models.py:119
msgid "otp try"
msgstr ""
#: apps/users/models.py:121
msgid "one time token"
msgstr ""
#: apps/users/models.py:125
msgid "balance"
msgstr ""
#: apps/users/models.py:131
msgid "gender"
msgstr ""
#: apps/users/models.py:132
msgid "education degree"
msgstr ""
#: apps/users/models.py:134
msgid "education field"
msgstr ""
#: apps/users/models.py:135
msgid "birthdate"
msgstr ""
#: apps/users/models.py:137
msgid "last update"
msgstr ""
#: apps/users/models.py:138
msgid "last login"
msgstr ""
#: apps/users/models.py:139
msgid "date joined"
msgstr ""
#: apps/users/models.py:156
msgid "otp expire time not reached."
msgstr ""
#: apps/users/models.py:250
msgid "no name"
msgstr ""
#: apps/users/serializers.py:11
msgid "Phone number must be entered in the format: '+989999999999'."
msgstr ""
#: apps/users/serializers.py:76
msgid "otp expire time not reached"
msgstr ""
#: apps/users/serializers.py:81
msgid "otp expire time not reached yet"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:6
msgid "Are you sure to delete the application"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:12
#: templates/oauth2_provider/authorize.html:29
msgid "Cancel"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:13
#: templates/oauth2_provider/application_detail.html:53
#: templates/oauth2_provider/authorized-token-delete.html:7
msgid "Delete"
msgstr ""
#: templates/oauth2_provider/application_detail.html:10
msgid "Client id"
msgstr ""
#: templates/oauth2_provider/application_detail.html:15
msgid "Client secret"
msgstr ""
#: templates/oauth2_provider/application_detail.html:20
msgid "Hash client secret"
msgstr ""
#: templates/oauth2_provider/application_detail.html:21
msgid "yes,no"
msgstr ""
#: templates/oauth2_provider/application_detail.html:25
msgid "Client type"
msgstr ""
#: templates/oauth2_provider/application_detail.html:30
msgid "Authorization Grant Type"
msgstr ""
#: templates/oauth2_provider/application_detail.html:35
msgid "Redirect Uris"
msgstr ""
#: templates/oauth2_provider/application_detail.html:40
msgid "Post Logout Redirect Uris"
msgstr ""
#: templates/oauth2_provider/application_detail.html:45
msgid "Allowed Origins"
msgstr ""
#: templates/oauth2_provider/application_detail.html:51
#: templates/oauth2_provider/application_form.html:35
msgid "Go Back"
msgstr ""
#: templates/oauth2_provider/application_detail.html:52
msgid "Edit"
msgstr ""
#: templates/oauth2_provider/application_form.html:9
msgid "Edit application"
msgstr ""
#: templates/oauth2_provider/application_form.html:37
msgid "Save"
msgstr ""
#: templates/oauth2_provider/application_list.html:6
msgid "Your applications"
msgstr ""
#: templates/oauth2_provider/application_list.html:14
msgid "New Application"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "No applications defined"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "Click here"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "if you want to register a new one"
msgstr ""
#: templates/oauth2_provider/application_registration_form.html:5
msgid "Register a new application"
msgstr ""
#: templates/oauth2_provider/authorize.html:8
#: templates/oauth2_provider/authorize.html:30
msgid "Authorize"
msgstr ""
#: templates/oauth2_provider/authorize.html:17
msgid "Application requires the following permissions"
msgstr ""
#: templates/oauth2_provider/authorized-token-delete.html:6
msgid "Are you sure you want to delete this token?"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:6
msgid "Tokens"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:11
msgid "revoke"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:19
msgid "There are no authorized tokens yet."
msgstr ""

Binary file not shown.

View file

@ -0,0 +1,345 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 10:43+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: accounts/settings.py:222
msgid "English"
msgstr ""
#: accounts/settings.py:223
msgid "Persian"
msgstr ""
#: accounts/settings.py:224
msgid "Arabic"
msgstr ""
#: accounts/settings.py:225
msgid "Turkish"
msgstr ""
#: apps/gooyal_oauth2/views/introspect.py:153
msgid "Invalid credentials given."
msgstr ""
#: apps/gooyal_oauth2/views/introspect.py:154
msgid "invalid_grant"
msgstr ""
#: apps/users/constans.py:9
msgid "male"
msgstr ""
#: apps/users/constans.py:10
msgid "female"
msgstr ""
#: apps/users/constans.py:14
msgid "Primary school"
msgstr ""
#: apps/users/constans.py:15
msgid "Middle school"
msgstr ""
#: apps/users/constans.py:16
msgid "High school"
msgstr ""
#: apps/users/constans.py:17
msgid "Associate degree"
msgstr ""
#: apps/users/constans.py:18
msgid "Bachelors degree"
msgstr ""
#: apps/users/constans.py:19
msgid "Masters degree"
msgstr ""
#: apps/users/constans.py:20
msgid "Doctorate"
msgstr ""
#: apps/users/forms.py:16 apps/users/models.py:117
msgid "otp"
msgstr ""
#: apps/users/forms.py:23
#, python-format
msgid ""
"Please enter a correct %(phone_number)s and otp. Note that both fields may "
"be case-sensitive."
msgstr ""
#: apps/users/forms.py:26
msgid "This account is inactive."
msgstr ""
#: apps/users/models.py:72
msgid "name"
msgstr ""
#: apps/users/models.py:73
msgid "first name"
msgstr ""
#: apps/users/models.py:74
msgid "last name"
msgstr ""
#: apps/users/models.py:75
msgid "email address"
msgstr ""
#: apps/users/models.py:84
msgid "Province"
msgstr ""
#: apps/users/models.py:85
msgid "City"
msgstr ""
#: apps/users/models.py:86
msgid "postal_code"
msgstr ""
#: apps/users/models.py:87
msgid "address"
msgstr ""
#: apps/users/models.py:90
msgid "staff status"
msgstr ""
#: apps/users/models.py:92
msgid "Designates whether the user can log into this admin site."
msgstr ""
#: apps/users/models.py:95
msgid "active"
msgstr ""
#: apps/users/models.py:98
msgid ""
"Designates whether this user should be treated as active. Unselect this "
"instead of deleting accounts."
msgstr ""
#: apps/users/models.py:102
msgid "password"
msgstr ""
#: apps/users/models.py:105
msgid "username"
msgstr ""
#: apps/users/models.py:108
msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
msgstr ""
#: apps/users/models.py:111
msgid "A user with that username already exists."
msgstr ""
#: apps/users/models.py:116
msgid "phone number"
msgstr ""
#: apps/users/models.py:118 apps/users/models.py:122 apps/users/models.py:124
msgid "otp expire"
msgstr ""
#: apps/users/models.py:119
msgid "otp try"
msgstr ""
#: apps/users/models.py:121
msgid "one time token"
msgstr ""
#: apps/users/models.py:125
msgid "balance"
msgstr ""
#: apps/users/models.py:131
msgid "gender"
msgstr ""
#: apps/users/models.py:132
msgid "education degree"
msgstr ""
#: apps/users/models.py:134
msgid "education field"
msgstr ""
#: apps/users/models.py:135
msgid "birthdate"
msgstr ""
#: apps/users/models.py:137
msgid "last update"
msgstr ""
#: apps/users/models.py:138
msgid "last login"
msgstr ""
#: apps/users/models.py:139
msgid "date joined"
msgstr ""
#: apps/users/models.py:156
msgid "otp expire time not reached."
msgstr ""
#: apps/users/models.py:250
msgid "no name"
msgstr ""
#: apps/users/serializers.py:11
msgid "Phone number must be entered in the format: '+989999999999'."
msgstr ""
#: apps/users/serializers.py:76
msgid "otp expire time not reached"
msgstr ""
#: apps/users/serializers.py:81
msgid "otp expire time not reached yet"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:6
msgid "Are you sure to delete the application"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:12
#: templates/oauth2_provider/authorize.html:29
msgid "Cancel"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:13
#: templates/oauth2_provider/application_detail.html:53
#: templates/oauth2_provider/authorized-token-delete.html:7
msgid "Delete"
msgstr ""
#: templates/oauth2_provider/application_detail.html:10
msgid "Client id"
msgstr ""
#: templates/oauth2_provider/application_detail.html:15
msgid "Client secret"
msgstr ""
#: templates/oauth2_provider/application_detail.html:20
msgid "Hash client secret"
msgstr ""
#: templates/oauth2_provider/application_detail.html:21
msgid "yes,no"
msgstr ""
#: templates/oauth2_provider/application_detail.html:25
msgid "Client type"
msgstr ""
#: templates/oauth2_provider/application_detail.html:30
msgid "Authorization Grant Type"
msgstr ""
#: templates/oauth2_provider/application_detail.html:35
msgid "Redirect Uris"
msgstr ""
#: templates/oauth2_provider/application_detail.html:40
msgid "Post Logout Redirect Uris"
msgstr ""
#: templates/oauth2_provider/application_detail.html:45
msgid "Allowed Origins"
msgstr ""
#: templates/oauth2_provider/application_detail.html:51
#: templates/oauth2_provider/application_form.html:35
msgid "Go Back"
msgstr ""
#: templates/oauth2_provider/application_detail.html:52
msgid "Edit"
msgstr ""
#: templates/oauth2_provider/application_form.html:9
msgid "Edit application"
msgstr ""
#: templates/oauth2_provider/application_form.html:37
msgid "Save"
msgstr ""
#: templates/oauth2_provider/application_list.html:6
msgid "Your applications"
msgstr ""
#: templates/oauth2_provider/application_list.html:14
msgid "New Application"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "No applications defined"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "Click here"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "if you want to register a new one"
msgstr ""
#: templates/oauth2_provider/application_registration_form.html:5
msgid "Register a new application"
msgstr ""
#: templates/oauth2_provider/authorize.html:8
#: templates/oauth2_provider/authorize.html:30
msgid "Authorize"
msgstr ""
#: templates/oauth2_provider/authorize.html:17
msgid "Application requires the following permissions"
msgstr ""
#: templates/oauth2_provider/authorized-token-delete.html:6
msgid "Are you sure you want to delete this token?"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:6
msgid "Tokens"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:11
msgid "revoke"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:19
msgid "There are no authorized tokens yet."
msgstr ""

Binary file not shown.

View file

@ -0,0 +1,345 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 10:43+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: accounts/settings.py:222
msgid "English"
msgstr ""
#: accounts/settings.py:223
msgid "Persian"
msgstr ""
#: accounts/settings.py:224
msgid "Arabic"
msgstr ""
#: accounts/settings.py:225
msgid "Turkish"
msgstr ""
#: apps/gooyal_oauth2/views/introspect.py:153
msgid "Invalid credentials given."
msgstr "اطلاعات وارد شده برای اعتبار سنجی نادرست است."
#: apps/gooyal_oauth2/views/introspect.py:154
msgid "invalid_grant"
msgstr "دسترسی اشتباه"
#: apps/users/constans.py:9
msgid "male"
msgstr ""
#: apps/users/constans.py:10
msgid "female"
msgstr ""
#: apps/users/constans.py:14
msgid "Primary school"
msgstr ""
#: apps/users/constans.py:15
msgid "Middle school"
msgstr ""
#: apps/users/constans.py:16
msgid "High school"
msgstr ""
#: apps/users/constans.py:17
msgid "Associate degree"
msgstr ""
#: apps/users/constans.py:18
msgid "Bachelors degree"
msgstr ""
#: apps/users/constans.py:19
msgid "Masters degree"
msgstr ""
#: apps/users/constans.py:20
msgid "Doctorate"
msgstr ""
#: apps/users/forms.py:16 apps/users/models.py:117
msgid "otp"
msgstr ""
#: apps/users/forms.py:23
#, python-format
msgid ""
"Please enter a correct %(phone_number)s and otp. Note that both fields may "
"be case-sensitive."
msgstr ""
#: apps/users/forms.py:26
msgid "This account is inactive."
msgstr ""
#: apps/users/models.py:72
msgid "name"
msgstr ""
#: apps/users/models.py:73
msgid "first name"
msgstr ""
#: apps/users/models.py:74
msgid "last name"
msgstr ""
#: apps/users/models.py:75
msgid "email address"
msgstr ""
#: apps/users/models.py:84
msgid "Province"
msgstr ""
#: apps/users/models.py:85
msgid "City"
msgstr ""
#: apps/users/models.py:86
msgid "postal_code"
msgstr ""
#: apps/users/models.py:87
msgid "address"
msgstr ""
#: apps/users/models.py:90
msgid "staff status"
msgstr ""
#: apps/users/models.py:92
msgid "Designates whether the user can log into this admin site."
msgstr ""
#: apps/users/models.py:95
msgid "active"
msgstr ""
#: apps/users/models.py:98
msgid ""
"Designates whether this user should be treated as active. Unselect this "
"instead of deleting accounts."
msgstr ""
#: apps/users/models.py:102
msgid "password"
msgstr ""
#: apps/users/models.py:105
msgid "username"
msgstr ""
#: apps/users/models.py:108
msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
msgstr ""
#: apps/users/models.py:111
msgid "A user with that username already exists."
msgstr ""
#: apps/users/models.py:116
msgid "phone number"
msgstr ""
#: apps/users/models.py:118 apps/users/models.py:122 apps/users/models.py:124
msgid "otp expire"
msgstr ""
#: apps/users/models.py:119
msgid "otp try"
msgstr ""
#: apps/users/models.py:121
msgid "one time token"
msgstr ""
#: apps/users/models.py:125
msgid "balance"
msgstr ""
#: apps/users/models.py:131
msgid "gender"
msgstr ""
#: apps/users/models.py:132
msgid "education degree"
msgstr ""
#: apps/users/models.py:134
msgid "education field"
msgstr ""
#: apps/users/models.py:135
msgid "birthdate"
msgstr ""
#: apps/users/models.py:137
msgid "last update"
msgstr ""
#: apps/users/models.py:138
msgid "last login"
msgstr ""
#: apps/users/models.py:139
msgid "date joined"
msgstr ""
#: apps/users/models.py:156
msgid "otp expire time not reached."
msgstr "رمز یکبار مصرف قبلی هنوز معتبر است."
#: apps/users/models.py:250
msgid "no name"
msgstr ""
#: apps/users/serializers.py:11
msgid "Phone number must be entered in the format: '+989999999999'."
msgstr ""
#: apps/users/serializers.py:76
msgid "otp expire time not reached"
msgstr "رمز یکبار مصرف قبلی هنوز معتبر است."
#: apps/users/serializers.py:81
msgid "otp expire time not reached yet"
msgstr "رمز یکبار مصرف قبلی هنوز معتبر است."
#: templates/oauth2_provider/application_confirm_delete.html:6
msgid "Are you sure to delete the application"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:12
#: templates/oauth2_provider/authorize.html:29
msgid "Cancel"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:13
#: templates/oauth2_provider/application_detail.html:53
#: templates/oauth2_provider/authorized-token-delete.html:7
msgid "Delete"
msgstr ""
#: templates/oauth2_provider/application_detail.html:10
msgid "Client id"
msgstr ""
#: templates/oauth2_provider/application_detail.html:15
msgid "Client secret"
msgstr ""
#: templates/oauth2_provider/application_detail.html:20
msgid "Hash client secret"
msgstr ""
#: templates/oauth2_provider/application_detail.html:21
msgid "yes,no"
msgstr ""
#: templates/oauth2_provider/application_detail.html:25
msgid "Client type"
msgstr ""
#: templates/oauth2_provider/application_detail.html:30
msgid "Authorization Grant Type"
msgstr ""
#: templates/oauth2_provider/application_detail.html:35
msgid "Redirect Uris"
msgstr ""
#: templates/oauth2_provider/application_detail.html:40
msgid "Post Logout Redirect Uris"
msgstr ""
#: templates/oauth2_provider/application_detail.html:45
msgid "Allowed Origins"
msgstr ""
#: templates/oauth2_provider/application_detail.html:51
#: templates/oauth2_provider/application_form.html:35
msgid "Go Back"
msgstr ""
#: templates/oauth2_provider/application_detail.html:52
msgid "Edit"
msgstr ""
#: templates/oauth2_provider/application_form.html:9
msgid "Edit application"
msgstr ""
#: templates/oauth2_provider/application_form.html:37
msgid "Save"
msgstr ""
#: templates/oauth2_provider/application_list.html:6
msgid "Your applications"
msgstr ""
#: templates/oauth2_provider/application_list.html:14
msgid "New Application"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "No applications defined"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "Click here"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "if you want to register a new one"
msgstr ""
#: templates/oauth2_provider/application_registration_form.html:5
msgid "Register a new application"
msgstr ""
#: templates/oauth2_provider/authorize.html:8
#: templates/oauth2_provider/authorize.html:30
msgid "Authorize"
msgstr ""
#: templates/oauth2_provider/authorize.html:17
msgid "Application requires the following permissions"
msgstr ""
#: templates/oauth2_provider/authorized-token-delete.html:6
msgid "Are you sure you want to delete this token?"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:6
msgid "Tokens"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:11
msgid "revoke"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:19
msgid "There are no authorized tokens yet."
msgstr ""

Binary file not shown.

View file

@ -0,0 +1,345 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-02-03 10:43+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: accounts/settings.py:222
msgid "English"
msgstr ""
#: accounts/settings.py:223
msgid "Persian"
msgstr ""
#: accounts/settings.py:224
msgid "Arabic"
msgstr ""
#: accounts/settings.py:225
msgid "Turkish"
msgstr ""
#: apps/gooyal_oauth2/views/introspect.py:153
msgid "Invalid credentials given."
msgstr ""
#: apps/gooyal_oauth2/views/introspect.py:154
msgid "invalid_grant"
msgstr ""
#: apps/users/constans.py:9
msgid "male"
msgstr ""
#: apps/users/constans.py:10
msgid "female"
msgstr ""
#: apps/users/constans.py:14
msgid "Primary school"
msgstr ""
#: apps/users/constans.py:15
msgid "Middle school"
msgstr ""
#: apps/users/constans.py:16
msgid "High school"
msgstr ""
#: apps/users/constans.py:17
msgid "Associate degree"
msgstr ""
#: apps/users/constans.py:18
msgid "Bachelors degree"
msgstr ""
#: apps/users/constans.py:19
msgid "Masters degree"
msgstr ""
#: apps/users/constans.py:20
msgid "Doctorate"
msgstr ""
#: apps/users/forms.py:16 apps/users/models.py:117
msgid "otp"
msgstr ""
#: apps/users/forms.py:23
#, python-format
msgid ""
"Please enter a correct %(phone_number)s and otp. Note that both fields may "
"be case-sensitive."
msgstr ""
#: apps/users/forms.py:26
msgid "This account is inactive."
msgstr ""
#: apps/users/models.py:72
msgid "name"
msgstr ""
#: apps/users/models.py:73
msgid "first name"
msgstr ""
#: apps/users/models.py:74
msgid "last name"
msgstr ""
#: apps/users/models.py:75
msgid "email address"
msgstr ""
#: apps/users/models.py:84
msgid "Province"
msgstr ""
#: apps/users/models.py:85
msgid "City"
msgstr ""
#: apps/users/models.py:86
msgid "postal_code"
msgstr ""
#: apps/users/models.py:87
msgid "address"
msgstr ""
#: apps/users/models.py:90
msgid "staff status"
msgstr ""
#: apps/users/models.py:92
msgid "Designates whether the user can log into this admin site."
msgstr ""
#: apps/users/models.py:95
msgid "active"
msgstr ""
#: apps/users/models.py:98
msgid ""
"Designates whether this user should be treated as active. Unselect this "
"instead of deleting accounts."
msgstr ""
#: apps/users/models.py:102
msgid "password"
msgstr ""
#: apps/users/models.py:105
msgid "username"
msgstr ""
#: apps/users/models.py:108
msgid "Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only."
msgstr ""
#: apps/users/models.py:111
msgid "A user with that username already exists."
msgstr ""
#: apps/users/models.py:116
msgid "phone number"
msgstr ""
#: apps/users/models.py:118 apps/users/models.py:122 apps/users/models.py:124
msgid "otp expire"
msgstr ""
#: apps/users/models.py:119
msgid "otp try"
msgstr ""
#: apps/users/models.py:121
msgid "one time token"
msgstr ""
#: apps/users/models.py:125
msgid "balance"
msgstr ""
#: apps/users/models.py:131
msgid "gender"
msgstr ""
#: apps/users/models.py:132
msgid "education degree"
msgstr ""
#: apps/users/models.py:134
msgid "education field"
msgstr ""
#: apps/users/models.py:135
msgid "birthdate"
msgstr ""
#: apps/users/models.py:137
msgid "last update"
msgstr ""
#: apps/users/models.py:138
msgid "last login"
msgstr ""
#: apps/users/models.py:139
msgid "date joined"
msgstr ""
#: apps/users/models.py:156
msgid "otp expire time not reached."
msgstr ""
#: apps/users/models.py:250
msgid "no name"
msgstr ""
#: apps/users/serializers.py:11
msgid "Phone number must be entered in the format: '+989999999999'."
msgstr ""
#: apps/users/serializers.py:76
msgid "otp expire time not reached"
msgstr ""
#: apps/users/serializers.py:81
msgid "otp expire time not reached yet"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:6
msgid "Are you sure to delete the application"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:12
#: templates/oauth2_provider/authorize.html:29
msgid "Cancel"
msgstr ""
#: templates/oauth2_provider/application_confirm_delete.html:13
#: templates/oauth2_provider/application_detail.html:53
#: templates/oauth2_provider/authorized-token-delete.html:7
msgid "Delete"
msgstr ""
#: templates/oauth2_provider/application_detail.html:10
msgid "Client id"
msgstr ""
#: templates/oauth2_provider/application_detail.html:15
msgid "Client secret"
msgstr ""
#: templates/oauth2_provider/application_detail.html:20
msgid "Hash client secret"
msgstr ""
#: templates/oauth2_provider/application_detail.html:21
msgid "yes,no"
msgstr ""
#: templates/oauth2_provider/application_detail.html:25
msgid "Client type"
msgstr ""
#: templates/oauth2_provider/application_detail.html:30
msgid "Authorization Grant Type"
msgstr ""
#: templates/oauth2_provider/application_detail.html:35
msgid "Redirect Uris"
msgstr ""
#: templates/oauth2_provider/application_detail.html:40
msgid "Post Logout Redirect Uris"
msgstr ""
#: templates/oauth2_provider/application_detail.html:45
msgid "Allowed Origins"
msgstr ""
#: templates/oauth2_provider/application_detail.html:51
#: templates/oauth2_provider/application_form.html:35
msgid "Go Back"
msgstr ""
#: templates/oauth2_provider/application_detail.html:52
msgid "Edit"
msgstr ""
#: templates/oauth2_provider/application_form.html:9
msgid "Edit application"
msgstr ""
#: templates/oauth2_provider/application_form.html:37
msgid "Save"
msgstr ""
#: templates/oauth2_provider/application_list.html:6
msgid "Your applications"
msgstr ""
#: templates/oauth2_provider/application_list.html:14
msgid "New Application"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "No applications defined"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "Click here"
msgstr ""
#: templates/oauth2_provider/application_list.html:17
msgid "if you want to register a new one"
msgstr ""
#: templates/oauth2_provider/application_registration_form.html:5
msgid "Register a new application"
msgstr ""
#: templates/oauth2_provider/authorize.html:8
#: templates/oauth2_provider/authorize.html:30
msgid "Authorize"
msgstr ""
#: templates/oauth2_provider/authorize.html:17
msgid "Application requires the following permissions"
msgstr ""
#: templates/oauth2_provider/authorized-token-delete.html:6
msgid "Are you sure you want to delete this token?"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:6
msgid "Tokens"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:11
msgid "revoke"
msgstr ""
#: templates/oauth2_provider/authorized-tokens.html:19
msgid "There are no authorized tokens yet."
msgstr ""

16
main/asgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
ASGI config for accounts project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings')
application = get_asgi_application()

View file

@ -1,6 +1,6 @@
#!/usr/bin/env python #!/usr/bin/env python
import os 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 from utils.broker import get_rpc_broker_consumer

View file

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

View file

View file

@ -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 / 'log'
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,
# },
# },
# }

View file

@ -11,12 +11,12 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
""" """
from pathlib import Path from pathlib import Path
from decouple import config from decouple import config, Csv
from django.utils.translation import gettext_lazy as _
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
@ -48,14 +48,14 @@ INSTALLED_APPS = [
'crispy_forms', 'crispy_forms',
'crispy_bootstrap5', 'crispy_bootstrap5',
'django_filters', 'django_filters',
'jalali_date', # 'jalali_date',
'django_minio_backend.apps.DjangoMinioBackendConfig', 'django_minio_backend.apps.DjangoMinioBackendConfig',
# local apps # local apps
'apps.core', 'apps.core',
'apps.users', 'apps.users',
'apps.gooyal_oauth2', 'apps.gooyal_oauth2',
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
@ -63,7 +63,7 @@ MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
'utils.i18n.ForceAdminLanguageMiddleware', # 'utils.i18n.ForceAdminLanguageMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
@ -119,9 +119,16 @@ REST_FRAMEWORK = {
# 'DEFAULT_THROTTLE_CLASSES': [ # 'DEFAULT_THROTTLE_CLASSES': [
# 'rest_framework.throttling.AnonRateThrottle', # 'rest_framework.throttling.AnonRateThrottle',
# ], # ],
'DEFAULT_THROTTLE_CLASSES': [
'apps.gooyal_oauth2.throttling.TokenLimitThrottle',
# 'rest_framework.throttling.AnonRateThrottle',
# 'rest_framework.throttling.UserRateThrottle'
],
'DEFAULT_THROTTLE_RATES': { 'DEFAULT_THROTTLE_RATES': {
'otp_min': '2/min', 'otp_min': '2/min',
'otp_day': '50/day', 'otp_day': '50/day',
'numbered_otp_min': '1/min',
'numbered_otp_day': '10/day',
}, },
'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', 'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler',
} }
@ -135,8 +142,7 @@ SPECTACULAR_SETTINGS = {
"PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"], "PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"],
} }
ROOT_URLCONF = 'main.urls'
ROOT_URLCONF = 'accounts.urls'
TEMPLATES = [ TEMPLATES = [
{ {
@ -160,7 +166,7 @@ AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', 'django.contrib.auth.backends.ModelBackend',
) )
WSGI_APPLICATION = 'accounts.wsgi.application' WSGI_APPLICATION = 'main.wsgi.application'
# Database # Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases # https://docs.djangoproject.com/en/5.0/ref/settings/#databases
@ -180,7 +186,12 @@ DATABASES = {
'PASSWORD': config('DB_PASSWORD'), 'PASSWORD': config('DB_PASSWORD'),
'HOST': config('DB_HOST', '127.0.0.1'), 'HOST': config('DB_HOST', '127.0.0.1'),
'PORT': config('DB_PORT', ''), 'PORT': config('DB_PORT', ''),
} "CONN_MAX_AGE": 0,
"OPTIONS": {
"pool": True,
},
},
} }
# Password validation # Password validation
@ -201,11 +212,10 @@ AUTH_PASSWORD_VALIDATORS = [
}, },
] ]
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/ # https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en' LANGUAGE_CODE = 'fa'
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
@ -215,11 +225,17 @@ USE_L10N = True
USE_TZ = True USE_TZ = True
LANGUAGES = [
('en', _('English')),
('fa', _('Persian')),
('ar', _('Arabic')),
('tr', _('Turkish')),
]
LOCALE_PATHS = [ LOCALE_PATHS = [
BASE_DIR / 'locale', BASE_DIR / 'locale',
] ]
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/ # https://docs.djangoproject.com/en/5.0/howto/static-files/
@ -229,23 +245,18 @@ MEDIA_ROOT = BASE_DIR / 'media'
STATIC_URL = '/static/' STATIC_URL = '/static/'
if DEBUG == True: if DEBUG == True:
STATICFILES_DIRS = ( STATICFILES_DIRS = (
BASE_DIR / 'static', BASE_DIR / 'static',
) )
else: else:
STATIC_ROOT = BASE_DIR / 'static' STATIC_ROOT = BASE_DIR / 'static'
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
LOGIN_URL = '/users/login/' LOGIN_URL = '/users/login/'
# LOGIN_DEFAULT_REDIRECT_URL = '/' # LOGIN_DEFAULT_REDIRECT_URL = '/'
@ -255,7 +266,7 @@ CRISPY_TEMPLATE_PACK = "bootstrap5"
AUTH_USER_MODEL = 'users.User' AUTH_USER_MODEL = 'users.User'
CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOW_ALL_ORIGINS = True
CSRF_TRUSTED_ORIGINS = ['http://*.gooyal.com', 'https://*.gooyal.com'] CSRF_TRUSTED_ORIGINS = ['http://*.gooyal.com', 'https://*.gooyal.com', 'http://*.gooyal.ir', 'https://*.gooyal.ir']
JALALI_DATE_DEFAULTS = { JALALI_DATE_DEFAULTS = {
'Strftime': { 'Strftime': {
@ -269,8 +280,10 @@ PAYAM_SMS_USERNAME = config('PAYAM_SMS_USERNAME')
PAYAM_SMS_PASSWORD = config('PAYAM_SMS_PASSWORD') PAYAM_SMS_PASSWORD = config('PAYAM_SMS_PASSWORD')
PAYAM_SMS_CLIENT_ID = config('PAYAM_SMS_CLIENT_ID') PAYAM_SMS_CLIENT_ID = config('PAYAM_SMS_CLIENT_ID')
PAYAM_SMS_CLIENT_SECRET = config('PAYAM_SMS_CLIENT_SECRET') PAYAM_SMS_CLIENT_SECRET = config('PAYAM_SMS_CLIENT_SECRET')
SMS_OTP_SIGNITURE = config('SMS_OTP_SIGNITURE', '')
SMS_OTP_SIGNITURE = config('SMS_OTP_SIGNITURE', '')
SMS_SEND = config('SMS_SEND', True, cast=bool)
TEST_PHONENUMBERS = config('TEST_PHONENUMBERS', [], cast=Csv(post_process=list))
CACHES = { CACHES = {
"default": { "default": {
@ -282,12 +295,42 @@ CACHES = {
} }
} }
LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str)
from main.other_settings.logging import LOGGING
from datetime import timedelta from datetime import timedelta
from typing import List, Tuple from typing import List, Tuple
STORAGES = { STORAGES = {
"default": { "default": {
"BACKEND": "django_minio_backend.models.MinioBackend", "BACKEND": "django_minio_backend.models.MinioBackend",
"OPTIONS": {
"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.ir'),
# Default is same as MINIO_ENDPOINT
"MINIO_EXTERNAL_ENDPOINT_USE_HTTPS": config('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS', default=True, cast=bool),
# Default is same as MINIO_USE_HTTPS
"MINIO_REGION": None, # Default is set to None
"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_DEFAULT_BUCKET": config('MINIO_MEDIA_FILES_BUCKET'), # replacement for MEDIA_ROOT
# MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT
"MINIO_PRIVATE_BUCKETS": [
'default',
],
"MINIO_PUBLIC_BUCKETS": [
'default-public',
config('MINIO_MEDIA_FILES_BUCKET')
],
"MINIO_POLICY_HOOKS": [],
"MINIO_BUCKET_CHECK_ON_SAVE": True # Default: True // Creates bucket if missing, then save
},
}, },
"staticfiles": { "staticfiles": {
# "BACKEND": "django_minio_backend.models.MinioBackendStatic", # "BACKEND": "django_minio_backend.models.MinioBackendStatic",
@ -296,29 +339,6 @@ 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_REGION = None # Default is set to None
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_MEDIA_FILES_BUCKET = config('MINIO_MEDIA_FILES_BUCKET') # replacement for MEDIA_ROOT
# MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT
MINIO_PRIVATE_BUCKETS = [
'default',
]
MINIO_PUBLIC_BUCKETS = [
'default-public',
MINIO_MEDIA_FILES_BUCKET
]
MINIO_POLICY_HOOKS: List[Tuple[str, dict]] = []
MINIO_BUCKET_CHECK_ON_SAVE = True # Default: True // Creates bucket if missing, then save
# Custom HTTP Client (OPTIONAL) # Custom HTTP Client (OPTIONAL)
import os import os
import certifi import certifi
@ -337,3 +357,30 @@ MINIO_HTTP_CLIENT: urllib3.poolmanager.PoolManager = urllib3.PoolManager(
status_forcelist=[500, 502, 503, 504] status_forcelist=[500, 502, 503, 504]
) )
) )
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.ir') # Default is same as MINIO_ENDPOINT
MINIO_EXTERNAL_ENDPOINT_USE_HTTPS = config('MINIO_EXTERNAL_ENDPOINT_USE_HTTPS', default=True,
cast=bool) # Default is same as MINIO_USE_HTTPS
MINIO_REGION = None # Default is set to None
MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY')
MINIO_SECRET_KEY = config('MINIO_SECRET_KEY')
MINIO_URL_EXPIRY_HOURS = timedelta(days=1) # Default is 7 days (longest) if not defined
MINIO_CONSISTENCY_CHECK_ON_START = False
MINIO_MEDIA_FILES_BUCKET = config('MINIO_MEDIA_FILES_BUCKET') # replacement for MEDIA_ROOT
# MINIO_STATIC_FILES_BUCKET = 'my-static-files-bucket' # replacement for STATIC_ROOT
MINIO_PRIVATE_BUCKETS = [
'default',
]
MINIO_PUBLIC_BUCKETS = [
'default-public',
MINIO_MEDIA_FILES_BUCKET
]
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='')

View file

@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application 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() application = get_wsgi_application()

View file

@ -5,7 +5,7 @@ import sys
def main(): def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'accounts.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings')
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:

View file

@ -16,6 +16,10 @@ django-crispy-forms
crispy_bootstrap5 crispy_bootstrap5
django-jalali-date django-jalali-date
django-betterforms django-betterforms
psycopg[binary] psycopg[binary,pool]
django_redis django_redis
django_minio_backend django_minio_backend
daphne
Twisted[tls,http2]
packaging==25.0
unidecode

View file

@ -1,5 +1,5 @@
# #
# This file is autogenerated by pip-compile with Python 3.11 # This file is autogenerated by pip-compile with Python 3.13
# by the following command: # by the following command:
# #
# pip-compile # pip-compile
@ -8,33 +8,41 @@ amqp==5.3.1
# via kombu # via kombu
argon2-cffi==25.1.0 argon2-cffi==25.1.0
# via minio # via minio
argon2-cffi-bindings==21.2.0 argon2-cffi-bindings==25.1.0
# via argon2-cffi # via argon2-cffi
asgiref==3.8.1 asgiref==3.11.1
# via # via
# daphne
# django # django
# django-cors-headers # django-cors-headers
async-timeout==5.0.1 attrs==25.4.0
# via redis
attrs==24.3.0
# via # via
# jsonschema # jsonschema
# referencing # referencing
billiard==4.2.1 # service-identity
# twisted
autobahn==25.12.2
# via daphne
automat==25.4.16
# via twisted
billiard==4.2.4
# via celery # via celery
celery==5.4.0 cbor2==5.8.0
# via autobahn
celery==5.6.2
# via -r requirements.in # via -r requirements.in
certifi==2024.12.14 certifi==2026.1.4
# via # via
# minio # minio
# requests # requests
cffi==1.17.1 cffi==2.0.0
# via # via
# argon2-cffi-bindings # argon2-cffi-bindings
# autobahn
# cryptography # cryptography
charset-normalizer==3.4.0 charset-normalizer==3.4.4
# via requests # via requests
click==8.1.7 click==8.3.1
# via # via
# celery # celery
# click-didyoumean # click-didyoumean
@ -42,15 +50,23 @@ click==8.1.7
# click-repl # click-repl
click-didyoumean==0.3.1 click-didyoumean==0.3.1
# via celery # via celery
click-plugins==1.1.1 click-plugins==1.1.1.2
# via celery # via celery
click-repl==0.3.0 click-repl==0.3.0
# via celery # via celery
crispy-bootstrap5==2024.10 constantly==23.10.4
# via twisted
crispy-bootstrap5==2025.6
# via -r requirements.in # via -r requirements.in
cryptography==44.0.0 cryptography==46.0.5
# via jwcrypto # via
django==5.1.4 # autobahn
# jwcrypto
# pyopenssl
# service-identity
daphne==4.2.1
# via -r requirements.in
django==6.0.2
# via # via
# -r requirements.in # -r requirements.in
# crispy-bootstrap5 # crispy-bootstrap5
@ -64,108 +80,156 @@ django==5.1.4
# django-redis # django-redis
# djangorestframework # djangorestframework
# drf-spectacular # drf-spectacular
django-betterforms==2.0.0 django-betterforms==3.0.0
# via -r requirements.in # via -r requirements.in
django-cors-headers==4.6.0 django-cors-headers==4.9.0
# via -r requirements.in # via -r requirements.in
django-crispy-forms==2.3 django-crispy-forms==2.5
# via # via
# -r requirements.in # -r requirements.in
# crispy-bootstrap5 # crispy-bootstrap5
django-filter==24.3 django-filter==25.2
# via -r requirements.in # via -r requirements.in
django-jalali-date==1.1.3 django-jalali-date==2.0.0
# via -r requirements.in # via -r requirements.in
django-minio-backend==3.8.0 django-minio-backend==4.5.0
# via -r requirements.in # via -r requirements.in
django-oauth-toolkit==3.0.1 django-oauth-toolkit==3.2.0
# via -r requirements.in # via -r requirements.in
django-redis==6.0.0 django-redis==6.0.0
# via -r requirements.in # via -r requirements.in
djangorestframework==3.15.2 djangorestframework==3.16.1
# via # via
# -r requirements.in # -r requirements.in
# drf-spectacular # drf-spectacular
drf-spectacular==0.28.0 drf-spectacular==0.29.0
# via -r requirements.in # via -r requirements.in
gevent==24.11.1 gevent==25.9.1
# via -r requirements.in # via -r requirements.in
greenlet==3.1.1 greenlet==3.3.2
# via gevent # via gevent
gunicorn==23.0.0 gunicorn==25.1.0
# via -r requirements.in # via -r requirements.in
idna==3.10 h2==4.3.0
# via requests # via twisted
hpack==4.1.0
# via h2
hyperframe==6.1.0
# via h2
hyperlink==21.0.0
# via
# autobahn
# twisted
idna==3.11
# via
# hyperlink
# requests
# twisted
incremental==24.11.0
# via twisted
inflection==0.5.1 inflection==0.5.1
# via drf-spectacular # via drf-spectacular
jalali-core==1.0.0 jalali-core==1.0.0
# via jdatetime # via jdatetime
jdatetime==5.0.0 jdatetime==5.2.0
# via django-jalali-date # via django-jalali-date
jsonschema==4.23.0 jsonschema==4.26.0
# via drf-spectacular # via drf-spectacular
jsonschema-specifications==2024.10.1 jsonschema-specifications==2025.9.1
# via jsonschema # via jsonschema
jwcrypto==1.5.6 jwcrypto==1.5.6
# via django-oauth-toolkit # via django-oauth-toolkit
kombu==5.4.2 kombu==5.6.2
# via celery # via celery
markdown==3.7 markdown==3.10.2
# via -r requirements.in # via -r requirements.in
minio==7.2.16 minio==7.2.20
# via django-minio-backend # via django-minio-backend
oauthlib==3.2.2 msgpack==1.1.2
# via autobahn
oauthlib==3.3.1
# via django-oauth-toolkit # via django-oauth-toolkit
packaging==24.2 packaging==25.0
# via gunicorn # via
pillow==11.0.0 # -r requirements.in
# via -r requirements.in # gunicorn
prompt-toolkit==3.0.48 # incremental
# kombu
pillow==12.1.1
# via
# -r requirements.in
# django-minio-backend
priority==1.3.0
# via twisted
prompt-toolkit==3.0.52
# via click-repl # via click-repl
psycopg[binary]==3.2.3 psycopg[binary,pool]==3.3.3
# via -r requirements.in # via -r requirements.in
psycopg-binary==3.2.3 psycopg-binary==3.3.3
# via psycopg # via psycopg
pycparser==2.22 psycopg-pool==3.3.0
# via psycopg
py-ubjson==0.16.1
# via autobahn
pyasn1==0.6.2
# via
# pyasn1-modules
# service-identity
pyasn1-modules==0.4.2
# via service-identity
pycparser==3.0
# via cffi # via cffi
pycryptodome==3.23.0 pycryptodome==3.23.0
# via minio # via minio
pyopenssl==25.3.0
# via twisted
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
# via celery # via celery
python-decouple==3.8 python-decouple==3.8
# via -r requirements.in # via -r requirements.in
pyyaml==6.0.2 pyyaml==6.0.3
# via drf-spectacular # via drf-spectacular
redis==5.2.1 redis==7.2.0
# via # via
# -r requirements.in # -r requirements.in
# django-redis # django-redis
referencing==0.35.1 referencing==0.37.0
# via # via
# jsonschema # jsonschema
# jsonschema-specifications # jsonschema-specifications
requests==2.32.3 requests==2.32.5
# via django-oauth-toolkit # via django-oauth-toolkit
rpds-py==0.22.3 rpds-py==0.30.0
# via # via
# jsonschema # jsonschema
# referencing # referencing
service-identity==24.2.0
# via twisted
six==1.17.0 six==1.17.0
# via python-dateutil # via python-dateutil
sqlparse==0.5.3 sqlparse==0.5.5
# via django # via django
typing-extensions==4.12.2 twisted[http2,tls]==25.5.0
# via
# -r requirements.in
# daphne
txaio==25.12.2
# via autobahn
typing-extensions==4.15.0
# via # via
# jwcrypto # jwcrypto
# minio # minio
# psycopg # psycopg-pool
tzdata==2024.2 # twisted
# via tzdata==2025.3
# celery # via kombu
# kombu tzlocal==5.3.1
uritemplate==4.1.1 # via celery
ujson==5.11.0
# via autobahn
uritemplate==4.2.0
# via drf-spectacular # via drf-spectacular
urllib3==2.2.3 urllib3==2.6.3
# via # via
# minio # minio
# requests # requests
@ -174,12 +238,12 @@ vine==5.1.0
# amqp # amqp
# celery # celery
# kombu # kombu
wcwidth==0.2.13 wcwidth==0.6.0
# via prompt-toolkit # via prompt-toolkit
zope-event==5.0 zope-event==6.1
# via gevent # via gevent
zope-interface==7.2 zope-interface==8.2
# via gevent # via
# gevent
# The following packages are considered to be unsafe in a requirements file: # twisted
# setuptools unidecode==1.4.0

11
run.sh
View file

@ -1,8 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
while ! nc -z $DB_HOST 5432 ; do #while ! nc -z $DB_HOST 5432 ; do
echo "APP Waiting for the DB Server" # echo "APP Waiting for the DB Server"
sleep 3 # sleep 3
done #done
#python3 manage.py collectstatic --noinput #python3 manage.py collectstatic --noinput
python3 manage.py migrate python3 manage.py migrate
gunicorn 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 main.asgi:application

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,8 @@
"""A client library for accessing service"""
from .client import AuthenticatedClient, Client
__all__ = (
"AuthenticatedClient",
"Client",
)

View file

@ -0,0 +1 @@
"""Contains methods for accessing the API"""

View file

@ -0,0 +1 @@
"""Contains endpoint functions for accessing the API"""

View file

@ -0,0 +1,167 @@
from http import HTTPStatus
from typing import Any
from urllib.parse import quote
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.event import Event
from ...types import Response
def _get_kwargs(
user_uuid: str,
*,
body: Event,
) -> dict[str, Any]:
headers: dict[str, Any] = {}
_kwargs: dict[str, Any] = {
"method": "post",
"url": "/api/v2/promotions/application/{user_uuid}/event/".format(
user_uuid=quote(str(user_uuid), safe=""),
),
}
_kwargs["json"] = body.to_dict()
headers["Content-Type"] = "application/json"
_kwargs["headers"] = headers
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Event | None:
if response.status_code == 201:
response_201 = Event.from_dict(response.json())
return response_201
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Event]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
body: Event,
) -> Response[Event]:
"""
Args:
user_uuid (str):
body (Event):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Event]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
body=body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
user_uuid: str,
*,
client: AuthenticatedClient,
body: Event,
) -> Event | None:
"""
Args:
user_uuid (str):
body (Event):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Event
"""
return sync_detailed(
user_uuid=user_uuid,
client=client,
body=body,
).parsed
async def asyncio_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
body: Event,
) -> Response[Event]:
"""
Args:
user_uuid (str):
body (Event):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Event]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
body=body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
user_uuid: str,
*,
client: AuthenticatedClient,
body: Event,
) -> Event | None:
"""
Args:
user_uuid (str):
body (Event):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Event
"""
return (
await asyncio_detailed(
user_uuid=user_uuid,
client=client,
body=body,
)
).parsed

View file

@ -0,0 +1,185 @@
from http import HTTPStatus
from typing import Any
from urllib.parse import quote
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.paginated_user_plan_list import PaginatedUserPlanList
from ...types import UNSET, Response, Unset
def _get_kwargs(
user_uuid: str,
*,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> dict[str, Any]:
params: dict[str, Any] = {}
params["limit"] = limit
params["offset"] = offset
params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/api/v2/promotions/application/{user_uuid}/plan/".format(
user_uuid=quote(str(user_uuid), safe=""),
),
"params": params,
}
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> PaginatedUserPlanList | None:
if response.status_code == 200:
response_200 = PaginatedUserPlanList.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(
*, client: AuthenticatedClient | Client, response: httpx.Response
) -> Response[PaginatedUserPlanList]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> Response[PaginatedUserPlanList]:
"""
Args:
user_uuid (str):
limit (int | Unset):
offset (int | Unset):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PaginatedUserPlanList]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
limit=limit,
offset=offset,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> PaginatedUserPlanList | None:
"""
Args:
user_uuid (str):
limit (int | Unset):
offset (int | Unset):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PaginatedUserPlanList
"""
return sync_detailed(
user_uuid=user_uuid,
client=client,
limit=limit,
offset=offset,
).parsed
async def asyncio_detailed(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> Response[PaginatedUserPlanList]:
"""
Args:
user_uuid (str):
limit (int | Unset):
offset (int | Unset):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PaginatedUserPlanList]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
limit=limit,
offset=offset,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
user_uuid: str,
*,
client: AuthenticatedClient,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> PaginatedUserPlanList | None:
"""
Args:
user_uuid (str):
limit (int | Unset):
offset (int | Unset):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PaginatedUserPlanList
"""
return (
await asyncio_detailed(
user_uuid=user_uuid,
client=client,
limit=limit,
offset=offset,
)
).parsed

View file

@ -0,0 +1,181 @@
from http import HTTPStatus
from typing import Any
from urllib.parse import quote
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.promote import Promote
from ...types import Response
def _get_kwargs(
user_uuid: str,
id: str,
*,
body: Promote,
) -> dict[str, Any]:
headers: dict[str, Any] = {}
_kwargs: dict[str, Any] = {
"method": "post",
"url": "/api/v2/promotions/application/{user_uuid}/plan/{id}/promote/".format(
user_uuid=quote(str(user_uuid), safe=""),
id=quote(str(id), safe=""),
),
}
_kwargs["json"] = body.to_dict()
headers["Content-Type"] = "application/json"
_kwargs["headers"] = headers
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Promote | None:
if response.status_code == 200:
response_200 = Promote.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Promote]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
user_uuid: str,
id: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Response[Promote]:
"""
Args:
user_uuid (str):
id (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Promote]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
id=id,
body=body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
user_uuid: str,
id: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Promote | None:
"""
Args:
user_uuid (str):
id (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Promote
"""
return sync_detailed(
user_uuid=user_uuid,
id=id,
client=client,
body=body,
).parsed
async def asyncio_detailed(
user_uuid: str,
id: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Response[Promote]:
"""
Args:
user_uuid (str):
id (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Promote]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
id=id,
body=body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
user_uuid: str,
id: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Promote | None:
"""
Args:
user_uuid (str):
id (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Promote
"""
return (
await asyncio_detailed(
user_uuid=user_uuid,
id=id,
client=client,
body=body,
)
).parsed

View file

@ -0,0 +1,161 @@
from http import HTTPStatus
from typing import Any
from urllib.parse import quote
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.user_plan import UserPlan
from ...types import Response
def _get_kwargs(
user_uuid: str,
id: str,
) -> dict[str, Any]:
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/api/v2/promotions/application/{user_uuid}/plan/{id}/".format(
user_uuid=quote(str(user_uuid), safe=""),
id=quote(str(id), safe=""),
),
}
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> UserPlan | None:
if response.status_code == 200:
response_200 = UserPlan.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[UserPlan]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
user_uuid: str,
id: str,
*,
client: AuthenticatedClient,
) -> Response[UserPlan]:
"""
Args:
user_uuid (str):
id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[UserPlan]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
id=id,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
user_uuid: str,
id: str,
*,
client: AuthenticatedClient,
) -> UserPlan | None:
"""
Args:
user_uuid (str):
id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
UserPlan
"""
return sync_detailed(
user_uuid=user_uuid,
id=id,
client=client,
).parsed
async def asyncio_detailed(
user_uuid: str,
id: str,
*,
client: AuthenticatedClient,
) -> Response[UserPlan]:
"""
Args:
user_uuid (str):
id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[UserPlan]
"""
kwargs = _get_kwargs(
user_uuid=user_uuid,
id=id,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
user_uuid: str,
id: str,
*,
client: AuthenticatedClient,
) -> UserPlan | None:
"""
Args:
user_uuid (str):
id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
UserPlan
"""
return (
await asyncio_detailed(
user_uuid=user_uuid,
id=id,
client=client,
)
).parsed

View file

@ -0,0 +1 @@
"""Contains endpoint functions for accessing the API"""

View file

@ -0,0 +1,151 @@
from http import HTTPStatus
from typing import Any
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.promote import Promote
from ...types import Response
def _get_kwargs(
*,
body: Promote,
) -> dict[str, Any]:
headers: dict[str, Any] = {}
_kwargs: dict[str, Any] = {
"method": "post",
"url": "/promotions/api/v1/events/submit",
}
_kwargs["json"] = body.to_dict()
headers["Content-Type"] = "application/json"
_kwargs["headers"] = headers
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Promote | None:
if response.status_code == 201:
response_201 = Promote.from_dict(response.json())
return response_201
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Promote]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
*,
client: AuthenticatedClient,
body: Promote,
) -> Response[Promote]:
"""
Args:
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Promote]
"""
kwargs = _get_kwargs(
body=body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
*,
client: AuthenticatedClient,
body: Promote,
) -> Promote | None:
"""
Args:
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Promote
"""
return sync_detailed(
client=client,
body=body,
).parsed
async def asyncio_detailed(
*,
client: AuthenticatedClient,
body: Promote,
) -> Response[Promote]:
"""
Args:
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Promote]
"""
kwargs = _get_kwargs(
body=body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
*,
client: AuthenticatedClient,
body: Promote,
) -> Promote | None:
"""
Args:
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Promote
"""
return (
await asyncio_detailed(
client=client,
body=body,
)
).parsed

View file

@ -0,0 +1,167 @@
from http import HTTPStatus
from typing import Any
from urllib.parse import quote
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.promote import Promote
from ...types import Response
def _get_kwargs(
plan: str,
*,
body: Promote,
) -> dict[str, Any]:
headers: dict[str, Any] = {}
_kwargs: dict[str, Any] = {
"method": "post",
"url": "/promotions/api/v1/plans/{plan}/".format(
plan=quote(str(plan), safe=""),
),
}
_kwargs["json"] = body.to_dict()
headers["Content-Type"] = "application/json"
_kwargs["headers"] = headers
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Promote | None:
if response.status_code == 201:
response_201 = Promote.from_dict(response.json())
return response_201
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Promote]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
plan: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Response[Promote]:
"""
Args:
plan (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Promote]
"""
kwargs = _get_kwargs(
plan=plan,
body=body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
plan: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Promote | None:
"""
Args:
plan (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Promote
"""
return sync_detailed(
plan=plan,
client=client,
body=body,
).parsed
async def asyncio_detailed(
plan: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Response[Promote]:
"""
Args:
plan (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Promote]
"""
kwargs = _get_kwargs(
plan=plan,
body=body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
plan: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Promote | None:
"""
Args:
plan (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Promote
"""
return (
await asyncio_detailed(
plan=plan,
client=client,
body=body,
)
).parsed

View file

@ -0,0 +1,185 @@
from http import HTTPStatus
from typing import Any
from urllib.parse import quote
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.paginated_promotion_list import PaginatedPromotionList
from ...types import UNSET, Response, Unset
def _get_kwargs(
plan: str,
*,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> dict[str, Any]:
params: dict[str, Any] = {}
params["limit"] = limit
params["offset"] = offset
params = {k: v for k, v in params.items() if v is not UNSET and v is not None}
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/promotions/api/v1/plans/{plan}/promotins".format(
plan=quote(str(plan), safe=""),
),
"params": params,
}
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> PaginatedPromotionList | None:
if response.status_code == 200:
response_200 = PaginatedPromotionList.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(
*, client: AuthenticatedClient | Client, response: httpx.Response
) -> Response[PaginatedPromotionList]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
plan: str,
*,
client: AuthenticatedClient,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> Response[PaginatedPromotionList]:
"""
Args:
plan (str):
limit (int | Unset):
offset (int | Unset):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PaginatedPromotionList]
"""
kwargs = _get_kwargs(
plan=plan,
limit=limit,
offset=offset,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
plan: str,
*,
client: AuthenticatedClient,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> PaginatedPromotionList | None:
"""
Args:
plan (str):
limit (int | Unset):
offset (int | Unset):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PaginatedPromotionList
"""
return sync_detailed(
plan=plan,
client=client,
limit=limit,
offset=offset,
).parsed
async def asyncio_detailed(
plan: str,
*,
client: AuthenticatedClient,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> Response[PaginatedPromotionList]:
"""
Args:
plan (str):
limit (int | Unset):
offset (int | Unset):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[PaginatedPromotionList]
"""
kwargs = _get_kwargs(
plan=plan,
limit=limit,
offset=offset,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
plan: str,
*,
client: AuthenticatedClient,
limit: int | Unset = UNSET,
offset: int | Unset = UNSET,
) -> PaginatedPromotionList | None:
"""
Args:
plan (str):
limit (int | Unset):
offset (int | Unset):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
PaginatedPromotionList
"""
return (
await asyncio_detailed(
plan=plan,
client=client,
limit=limit,
offset=offset,
)
).parsed

View file

@ -0,0 +1,167 @@
from http import HTTPStatus
from typing import Any
from urllib.parse import quote
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.promote import Promote
from ...types import Response
def _get_kwargs(
id: str,
*,
body: Promote,
) -> dict[str, Any]:
headers: dict[str, Any] = {}
_kwargs: dict[str, Any] = {
"method": "post",
"url": "/promotions/api/v2/plans/{id}/promote/".format(
id=quote(str(id), safe=""),
),
}
_kwargs["json"] = body.to_dict()
headers["Content-Type"] = "application/json"
_kwargs["headers"] = headers
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Promote | None:
if response.status_code == 200:
response_200 = Promote.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[Promote]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
id: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Response[Promote]:
"""
Args:
id (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Promote]
"""
kwargs = _get_kwargs(
id=id,
body=body,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
id: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Promote | None:
"""
Args:
id (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Promote
"""
return sync_detailed(
id=id,
client=client,
body=body,
).parsed
async def asyncio_detailed(
id: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Response[Promote]:
"""
Args:
id (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[Promote]
"""
kwargs = _get_kwargs(
id=id,
body=body,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
id: str,
*,
client: AuthenticatedClient,
body: Promote,
) -> Promote | None:
"""
Args:
id (str):
body (Promote):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Promote
"""
return (
await asyncio_detailed(
id=id,
client=client,
body=body,
)
).parsed

View file

@ -0,0 +1,147 @@
from http import HTTPStatus
from typing import Any
from urllib.parse import quote
import httpx
from ... import errors
from ...client import AuthenticatedClient, Client
from ...models.user_plan import UserPlan
from ...types import Response
def _get_kwargs(
id: str,
) -> dict[str, Any]:
_kwargs: dict[str, Any] = {
"method": "get",
"url": "/promotions/api/v2/plans/{id}/".format(
id=quote(str(id), safe=""),
),
}
return _kwargs
def _parse_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> UserPlan | None:
if response.status_code == 200:
response_200 = UserPlan.from_dict(response.json())
return response_200
if client.raise_on_unexpected_status:
raise errors.UnexpectedStatus(response.status_code, response.content)
else:
return None
def _build_response(*, client: AuthenticatedClient | Client, response: httpx.Response) -> Response[UserPlan]:
return Response(
status_code=HTTPStatus(response.status_code),
content=response.content,
headers=response.headers,
parsed=_parse_response(client=client, response=response),
)
def sync_detailed(
id: str,
*,
client: AuthenticatedClient,
) -> Response[UserPlan]:
"""
Args:
id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[UserPlan]
"""
kwargs = _get_kwargs(
id=id,
)
response = client.get_httpx_client().request(
**kwargs,
)
return _build_response(client=client, response=response)
def sync(
id: str,
*,
client: AuthenticatedClient,
) -> UserPlan | None:
"""
Args:
id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
UserPlan
"""
return sync_detailed(
id=id,
client=client,
).parsed
async def asyncio_detailed(
id: str,
*,
client: AuthenticatedClient,
) -> Response[UserPlan]:
"""
Args:
id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
Response[UserPlan]
"""
kwargs = _get_kwargs(
id=id,
)
response = await client.get_async_httpx_client().request(**kwargs)
return _build_response(client=client, response=response)
async def asyncio(
id: str,
*,
client: AuthenticatedClient,
) -> UserPlan | None:
"""
Args:
id (str):
Raises:
errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True.
httpx.TimeoutException: If the request takes longer than Client.timeout.
Returns:
UserPlan
"""
return (
await asyncio_detailed(
id=id,
client=client,
)
).parsed

View file

@ -0,0 +1,268 @@
import ssl
from typing import Any
import httpx
from attrs import define, evolve, field
@define
class Client:
"""A class for keeping track of data related to the API
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
``cookies``: A dictionary of cookies to be sent with every request
``headers``: A dictionary of headers to be sent with every request
``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.
``follow_redirects``: Whether or not to follow redirects. Default value is False.
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Attributes:
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
argument to the constructor.
"""
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str = field(alias="base_url")
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
_timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout")
_verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl")
_follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects")
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
_client: httpx.Client | None = field(default=None, init=False)
_async_client: httpx.AsyncClient | None = field(default=None, init=False)
def with_headers(self, headers: dict[str, str]) -> "Client":
"""Get a new client matching this one with additional headers"""
if self._client is not None:
self._client.headers.update(headers)
if self._async_client is not None:
self._async_client.headers.update(headers)
return evolve(self, headers={**self._headers, **headers})
def with_cookies(self, cookies: dict[str, str]) -> "Client":
"""Get a new client matching this one with additional cookies"""
if self._client is not None:
self._client.cookies.update(cookies)
if self._async_client is not None:
self._async_client.cookies.update(cookies)
return evolve(self, cookies={**self._cookies, **cookies})
def with_timeout(self, timeout: httpx.Timeout) -> "Client":
"""Get a new client matching this one with a new timeout configuration"""
if self._client is not None:
self._client.timeout = timeout
if self._async_client is not None:
self._async_client.timeout = timeout
return evolve(self, timeout=timeout)
def set_httpx_client(self, client: httpx.Client) -> "Client":
"""Manually set the underlying httpx.Client
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._client = client
return self
def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None:
self._client = httpx.Client(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._client
def __enter__(self) -> "Client":
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
self.get_httpx_client().__enter__()
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "Client":
"""Manually set the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._async_client = async_client
return self
def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None:
self._async_client = httpx.AsyncClient(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._async_client
async def __aenter__(self) -> "Client":
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
await self.get_async_httpx_client().__aenter__()
return self
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
await self.get_async_httpx_client().__aexit__(*args, **kwargs)
@define
class AuthenticatedClient:
"""A Client which has been authenticated for use on secured endpoints
The following are accepted as keyword arguments and will be used to construct httpx Clients internally:
``base_url``: The base URL for the API, all requests are made to a relative path to this URL
``cookies``: A dictionary of cookies to be sent with every request
``headers``: A dictionary of headers to be sent with every request
``timeout``: The maximum amount of a time a request can take. API functions will raise
httpx.TimeoutException if this is exceeded.
``verify_ssl``: Whether or not to verify the SSL certificate of the API server. This should be True in production,
but can be set to False for testing purposes.
``follow_redirects``: Whether or not to follow redirects. Default value is False.
``httpx_args``: A dictionary of additional arguments to be passed to the ``httpx.Client`` and ``httpx.AsyncClient`` constructor.
Attributes:
raise_on_unexpected_status: Whether or not to raise an errors.UnexpectedStatus if the API returns a
status code that was not documented in the source OpenAPI document. Can also be provided as a keyword
argument to the constructor.
token: The token to use for authentication
prefix: The prefix to use for the Authorization header
auth_header_name: The name of the Authorization header
"""
raise_on_unexpected_status: bool = field(default=False, kw_only=True)
_base_url: str = field(alias="base_url")
_cookies: dict[str, str] = field(factory=dict, kw_only=True, alias="cookies")
_headers: dict[str, str] = field(factory=dict, kw_only=True, alias="headers")
_timeout: httpx.Timeout | None = field(default=None, kw_only=True, alias="timeout")
_verify_ssl: str | bool | ssl.SSLContext = field(default=True, kw_only=True, alias="verify_ssl")
_follow_redirects: bool = field(default=False, kw_only=True, alias="follow_redirects")
_httpx_args: dict[str, Any] = field(factory=dict, kw_only=True, alias="httpx_args")
_client: httpx.Client | None = field(default=None, init=False)
_async_client: httpx.AsyncClient | None = field(default=None, init=False)
token: str
prefix: str = "Bearer"
auth_header_name: str = "Authorization"
def with_headers(self, headers: dict[str, str]) -> "AuthenticatedClient":
"""Get a new client matching this one with additional headers"""
if self._client is not None:
self._client.headers.update(headers)
if self._async_client is not None:
self._async_client.headers.update(headers)
return evolve(self, headers={**self._headers, **headers})
def with_cookies(self, cookies: dict[str, str]) -> "AuthenticatedClient":
"""Get a new client matching this one with additional cookies"""
if self._client is not None:
self._client.cookies.update(cookies)
if self._async_client is not None:
self._async_client.cookies.update(cookies)
return evolve(self, cookies={**self._cookies, **cookies})
def with_timeout(self, timeout: httpx.Timeout) -> "AuthenticatedClient":
"""Get a new client matching this one with a new timeout configuration"""
if self._client is not None:
self._client.timeout = timeout
if self._async_client is not None:
self._async_client.timeout = timeout
return evolve(self, timeout=timeout)
def set_httpx_client(self, client: httpx.Client) -> "AuthenticatedClient":
"""Manually set the underlying httpx.Client
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._client = client
return self
def get_httpx_client(self) -> httpx.Client:
"""Get the underlying httpx.Client, constructing a new one if not previously set"""
if self._client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
self._client = httpx.Client(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._client
def __enter__(self) -> "AuthenticatedClient":
"""Enter a context manager for self.client—you cannot enter twice (see httpx docs)"""
self.get_httpx_client().__enter__()
return self
def __exit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for internal httpx.Client (see httpx docs)"""
self.get_httpx_client().__exit__(*args, **kwargs)
def set_async_httpx_client(self, async_client: httpx.AsyncClient) -> "AuthenticatedClient":
"""Manually set the underlying httpx.AsyncClient
**NOTE**: This will override any other settings on the client, including cookies, headers, and timeout.
"""
self._async_client = async_client
return self
def get_async_httpx_client(self) -> httpx.AsyncClient:
"""Get the underlying httpx.AsyncClient, constructing a new one if not previously set"""
if self._async_client is None:
self._headers[self.auth_header_name] = f"{self.prefix} {self.token}" if self.prefix else self.token
self._async_client = httpx.AsyncClient(
base_url=self._base_url,
cookies=self._cookies,
headers=self._headers,
timeout=self._timeout,
verify=self._verify_ssl,
follow_redirects=self._follow_redirects,
**self._httpx_args,
)
return self._async_client
async def __aenter__(self) -> "AuthenticatedClient":
"""Enter a context manager for underlying httpx.AsyncClient—you cannot enter twice (see httpx docs)"""
await self.get_async_httpx_client().__aenter__()
return self
async def __aexit__(self, *args: Any, **kwargs: Any) -> None:
"""Exit a context manager for underlying httpx.AsyncClient (see httpx docs)"""
await self.get_async_httpx_client().__aexit__(*args, **kwargs)

View file

@ -0,0 +1,16 @@
"""Contains shared errors types that can be raised from API functions"""
class UnexpectedStatus(Exception):
"""Raised by api functions when the response status an undocumented status and Client.raise_on_unexpected_status is True"""
def __init__(self, status_code: int, content: bytes):
self.status_code = status_code
self.content = content
super().__init__(
f"Unexpected status code: {status_code}\n\nResponse content:\n{content.decode(errors='ignore')}"
)
__all__ = ["UnexpectedStatus"]

View file

@ -0,0 +1,21 @@
"""Contains all the data models used in inputs/outputs"""
from .event import Event
from .paginated_promotion_list import PaginatedPromotionList
from .paginated_user_plan_list import PaginatedUserPlanList
from .promote import Promote
from .promotion import Promotion
from .state_enum import StateEnum
from .user_plan import UserPlan
from .user_recipient import UserRecipient
__all__ = (
"Event",
"PaginatedPromotionList",
"PaginatedUserPlanList",
"Promote",
"Promotion",
"StateEnum",
"UserPlan",
"UserRecipient",
)

View file

@ -0,0 +1,120 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, TypeVar, cast
from uuid import UUID
from attrs import define as _attrs_define
from attrs import field as _attrs_field
T = TypeVar("T", bound="Event")
@_attrs_define
class Event:
"""
Attributes:
label (str):
data (Any):
user (None | UUID):
application (None | UUID):
"""
label: str
data: Any
user: None | UUID
application: None | UUID
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
label = self.label
data = self.data
user: None | str
if isinstance(self.user, UUID):
user = str(self.user)
else:
user = self.user
application: None | str
if isinstance(self.application, UUID):
application = str(self.application)
else:
application = self.application
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"label": label,
"data": data,
"user": user,
"application": application,
}
)
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
d = dict(src_dict)
label = d.pop("label")
data = d.pop("data")
def _parse_user(data: object) -> None | UUID:
if data is None:
return data
try:
if not isinstance(data, str):
raise TypeError()
user_type_0 = UUID(data)
return user_type_0
except (TypeError, ValueError, AttributeError, KeyError):
pass
return cast(None | UUID, data)
user = _parse_user(d.pop("user"))
def _parse_application(data: object) -> None | UUID:
if data is None:
return data
try:
if not isinstance(data, str):
raise TypeError()
application_type_0 = UUID(data)
return application_type_0
except (TypeError, ValueError, AttributeError, KeyError):
pass
return cast(None | UUID, data)
application = _parse_application(d.pop("application"))
event = cls(
label=label,
data=data,
user=user,
application=application,
)
event.additional_properties = d
return event
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,125 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, TypeVar, cast
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.promotion import Promotion
T = TypeVar("T", bound="PaginatedPromotionList")
@_attrs_define
class PaginatedPromotionList:
"""
Attributes:
count (int): Example: 123.
results (list[Promotion]):
next_ (None | str | Unset): Example: http://api.example.org/accounts/?offset=400&limit=100.
previous (None | str | Unset): Example: http://api.example.org/accounts/?offset=200&limit=100.
"""
count: int
results: list[Promotion]
next_: None | str | Unset = UNSET
previous: None | str | Unset = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
count = self.count
results = []
for results_item_data in self.results:
results_item = results_item_data.to_dict()
results.append(results_item)
next_: None | str | Unset
if isinstance(self.next_, Unset):
next_ = UNSET
else:
next_ = self.next_
previous: None | str | Unset
if isinstance(self.previous, Unset):
previous = UNSET
else:
previous = self.previous
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"count": count,
"results": results,
}
)
if next_ is not UNSET:
field_dict["next"] = next_
if previous is not UNSET:
field_dict["previous"] = previous
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.promotion import Promotion
d = dict(src_dict)
count = d.pop("count")
results = []
_results = d.pop("results")
for results_item_data in _results:
results_item = Promotion.from_dict(results_item_data)
results.append(results_item)
def _parse_next_(data: object) -> None | str | Unset:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(None | str | Unset, data)
next_ = _parse_next_(d.pop("next", UNSET))
def _parse_previous(data: object) -> None | str | Unset:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(None | str | Unset, data)
previous = _parse_previous(d.pop("previous", UNSET))
paginated_promotion_list = cls(
count=count,
results=results,
next_=next_,
previous=previous,
)
paginated_promotion_list.additional_properties = d
return paginated_promotion_list
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,125 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, TypeVar, cast
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.user_plan import UserPlan
T = TypeVar("T", bound="PaginatedUserPlanList")
@_attrs_define
class PaginatedUserPlanList:
"""
Attributes:
count (int): Example: 123.
results (list[UserPlan]):
next_ (None | str | Unset): Example: http://api.example.org/accounts/?offset=400&limit=100.
previous (None | str | Unset): Example: http://api.example.org/accounts/?offset=200&limit=100.
"""
count: int
results: list[UserPlan]
next_: None | str | Unset = UNSET
previous: None | str | Unset = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
count = self.count
results = []
for results_item_data in self.results:
results_item = results_item_data.to_dict()
results.append(results_item)
next_: None | str | Unset
if isinstance(self.next_, Unset):
next_ = UNSET
else:
next_ = self.next_
previous: None | str | Unset
if isinstance(self.previous, Unset):
previous = UNSET
else:
previous = self.previous
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"count": count,
"results": results,
}
)
if next_ is not UNSET:
field_dict["next"] = next_
if previous is not UNSET:
field_dict["previous"] = previous
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.user_plan import UserPlan
d = dict(src_dict)
count = d.pop("count")
results = []
_results = d.pop("results")
for results_item_data in _results:
results_item = UserPlan.from_dict(results_item_data)
results.append(results_item)
def _parse_next_(data: object) -> None | str | Unset:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(None | str | Unset, data)
next_ = _parse_next_(d.pop("next", UNSET))
def _parse_previous(data: object) -> None | str | Unset:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(None | str | Unset, data)
previous = _parse_previous(d.pop("previous", UNSET))
paginated_user_plan_list = cls(
count=count,
results=results,
next_=next_,
previous=previous,
)
paginated_user_plan_list.additional_properties = d
return paginated_user_plan_list
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,91 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, TypeVar
from attrs import define as _attrs_define
from attrs import field as _attrs_field
if TYPE_CHECKING:
from ..models.promotion import Promotion
T = TypeVar("T", bound="Promote")
@_attrs_define
class Promote:
"""
Attributes:
promotions (list[Promotion]):
label (str):
data (Any):
"""
promotions: list[Promotion]
label: str
data: Any
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
promotions = []
for promotions_item_data in self.promotions:
promotions_item = promotions_item_data.to_dict()
promotions.append(promotions_item)
label = self.label
data = self.data
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"promotions": promotions,
"label": label,
"data": data,
}
)
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.promotion import Promotion
d = dict(src_dict)
promotions = []
_promotions = d.pop("promotions")
for promotions_item_data in _promotions:
promotions_item = Promotion.from_dict(promotions_item_data)
promotions.append(promotions_item)
label = d.pop("label")
data = d.pop("data")
promote = cls(
promotions=promotions,
label=label,
data=data,
)
promote.additional_properties = d
return promote
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,129 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, TypeVar, cast
from uuid import UUID
from attrs import define as _attrs_define
from attrs import field as _attrs_field
from ..models.state_enum import StateEnum
from ..types import UNSET, Unset
if TYPE_CHECKING:
from ..models.event import Event
T = TypeVar("T", bound="Promotion")
@_attrs_define
class Promotion:
"""
Attributes:
uuid (UUID):
event (Event):
user (UUID):
promotion_amount (int | None | Unset):
state (StateEnum | Unset): * `1` - created
* `2` - delayed
* `3` - pending
* `4` - incomplete
* `5` - success
* `6` - failed
* `7` - expected_failure
"""
uuid: UUID
event: Event
user: UUID
promotion_amount: int | None | Unset = UNSET
state: StateEnum | Unset = UNSET
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
uuid = str(self.uuid)
event = self.event.to_dict()
user = str(self.user)
promotion_amount: int | None | Unset
if isinstance(self.promotion_amount, Unset):
promotion_amount = UNSET
else:
promotion_amount = self.promotion_amount
state: int | Unset = UNSET
if not isinstance(self.state, Unset):
state = self.state.value
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"uuid": uuid,
"event": event,
"user": user,
}
)
if promotion_amount is not UNSET:
field_dict["promotion_amount"] = promotion_amount
if state is not UNSET:
field_dict["state"] = state
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.event import Event
d = dict(src_dict)
uuid = UUID(d.pop("uuid"))
event = Event.from_dict(d.pop("event"))
user = UUID(d.pop("user"))
def _parse_promotion_amount(data: object) -> int | None | Unset:
if data is None:
return data
if isinstance(data, Unset):
return data
return cast(int | None | Unset, data)
promotion_amount = _parse_promotion_amount(d.pop("promotion_amount", UNSET))
_state = d.pop("state", UNSET)
state: StateEnum | Unset
if isinstance(_state, Unset):
state = UNSET
else:
state = StateEnum(_state)
promotion = cls(
uuid=uuid,
event=event,
user=user,
promotion_amount=promotion_amount,
state=state,
)
promotion.additional_properties = d
return promotion
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,14 @@
from enum import IntEnum
class StateEnum(IntEnum):
VALUE_1 = 1
VALUE_2 = 2
VALUE_3 = 3
VALUE_4 = 4
VALUE_5 = 5
VALUE_6 = 6
VALUE_7 = 7
def __str__(self) -> str:
return str(self.value)

View file

@ -0,0 +1,113 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, TypeVar, cast
from attrs import define as _attrs_define
from attrs import field as _attrs_field
if TYPE_CHECKING:
from ..models.user_recipient import UserRecipient
T = TypeVar("T", bound="UserPlan")
@_attrs_define
class UserPlan:
"""
Attributes:
title (str):
banner (None | str):
description (str):
description_details (Any):
recipients (list[UserRecipient]):
"""
title: str
banner: None | str
description: str
description_details: Any
recipients: list[UserRecipient]
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
title = self.title
banner: None | str
banner = self.banner
description = self.description
description_details = self.description_details
recipients = []
for recipients_item_data in self.recipients:
recipients_item = recipients_item_data.to_dict()
recipients.append(recipients_item)
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"title": title,
"banner": banner,
"description": description,
"description_details": description_details,
"recipients": recipients,
}
)
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
from ..models.user_recipient import UserRecipient
d = dict(src_dict)
title = d.pop("title")
def _parse_banner(data: object) -> None | str:
if data is None:
return data
return cast(None | str, data)
banner = _parse_banner(d.pop("banner"))
description = d.pop("description")
description_details = d.pop("description_details")
recipients = []
_recipients = d.pop("recipients")
for recipients_item_data in _recipients:
recipients_item = UserRecipient.from_dict(recipients_item_data)
recipients.append(recipients_item)
user_plan = cls(
title=title,
banner=banner,
description=description,
description_details=description_details,
recipients=recipients,
)
user_plan.additional_properties = d
return user_plan
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1,69 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any, TypeVar
from attrs import define as _attrs_define
from attrs import field as _attrs_field
T = TypeVar("T", bound="UserRecipient")
@_attrs_define
class UserRecipient:
"""
Attributes:
label (str):
base_amount_field (str):
"""
label: str
base_amount_field: str
additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict)
def to_dict(self) -> dict[str, Any]:
label = self.label
base_amount_field = self.base_amount_field
field_dict: dict[str, Any] = {}
field_dict.update(self.additional_properties)
field_dict.update(
{
"label": label,
"base_amount_field": base_amount_field,
}
)
return field_dict
@classmethod
def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T:
d = dict(src_dict)
label = d.pop("label")
base_amount_field = d.pop("base_amount_field")
user_recipient = cls(
label=label,
base_amount_field=base_amount_field,
)
user_recipient.additional_properties = d
return user_recipient
@property
def additional_keys(self) -> list[str]:
return list(self.additional_properties.keys())
def __getitem__(self, key: str) -> Any:
return self.additional_properties[key]
def __setitem__(self, key: str, value: Any) -> None:
self.additional_properties[key] = value
def __delitem__(self, key: str) -> None:
del self.additional_properties[key]
def __contains__(self, key: str) -> bool:
return key in self.additional_properties

View file

@ -0,0 +1 @@
# Marker file for PEP 561

View file

@ -0,0 +1,54 @@
"""Contains some shared types for properties"""
from collections.abc import Mapping, MutableMapping
from http import HTTPStatus
from typing import IO, BinaryIO, Generic, Literal, TypeVar
from attrs import define
class Unset:
def __bool__(self) -> Literal[False]:
return False
UNSET: Unset = Unset()
# The types that `httpx.Client(files=)` can accept, copied from that library.
FileContent = IO[bytes] | bytes | str
FileTypes = (
# (filename, file (or bytes), content_type)
tuple[str | None, FileContent, str | None]
# (filename, file (or bytes), content_type, headers)
| tuple[str | None, FileContent, str | None, Mapping[str, str]]
)
RequestFiles = list[tuple[str, FileTypes]]
@define
class File:
"""Contains information for file uploads"""
payload: BinaryIO
file_name: str | None = None
mime_type: str | None = None
def to_tuple(self) -> FileTypes:
"""Return a tuple representation that httpx will accept for multipart/form-data"""
return self.file_name, self.payload, self.mime_type
T = TypeVar("T")
@define
class Response(Generic[T]):
"""A response from an endpoint"""
status_code: HTTPStatus
content: bytes
headers: MutableMapping[str, str]
parsed: T | None
__all__ = ["UNSET", "File", "FileTypes", "RequestFiles", "Response", "Unset"]

View file

@ -1,50 +0,0 @@
# TODO: this is time fourced code. refactor
import requests
from django.conf import settings
def get_access_token():
payam_sms_system_name = settings.PAYAM_SMS_SYSTEM_NAME
payam_sms_username = settings.PAYAM_SMS_USERNAME
payam_sms_password = settings.PAYAM_SMS_PASSWORD
payam_sms_client_id = settings.PAYAM_SMS_CLIENT_ID
payam_sms_client_secret = settings.PAYAM_SMS_CLIENT_SECRET
auth = (payam_sms_client_id, payam_sms_client_secret)
url = f"https://www.payamsms.com/auth/oauth/token?systemName={payam_sms_system_name}&username={payam_sms_username}&password={payam_sms_password}&scope=webservice&grant_type=password"
response_object = requests.post(url, auth=auth, json={})
response = response_object.json()
return response["access_token"]
def send_sms(phone_number, message):
try:
url = "https://www.payamsms.com/panel/webservice/send"
# access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJzb2JhbiIsInNjb3BlIjpbIndlYnNlcnZpY2UiXSwiZXhwIjoxNzQ0MTAyMDU5LCJ1c2VyRGV0YWlscyI6eyJpZCI6MjU0MCwidXNlck5hbWUiOiJzb2JhbiIsInJvbGUiOiJVU0VSIiwiZW5hYmxlZCI6dHJ1ZSwic3lzdGVtSWQiOjcyMiwiYWNjZXNzIjp7InJvbGUiOlsiYWRkIiwiZWRpdCIsImdldCIsImRlbGV0ZSJdLCJwaG9uZUJvb2siOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInNtcyI6WyJzaW5nbGUiLCJnZXRMb2NhbCIsImdldEdsb2JhbCIsImJ1bGsiLCJkYiJdLCJyZXBvcnQiOlsiZ2V0TW9HbG9iYWwiLCJnZXREYWlseUxvY2FsIiwiZ2V0TW9Mb2NhbCIsImdldERhaWx5R2xvYmFsIl0sIndzIjpbIndzIl0sInVzZXIiOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0IiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInRvb2xzIjpbInJhY2UiLCJwZXJpb2RpYyIsInBvbGxpbmciLCJzZWNyZXRvcnkiLCJhdXRvYW5zd2VyIl0sImZpbmFuY2UiOlsidGRyIiwiY2hhcmdlIl19LCJyb2xlSWQiOjQ0NDAsImNoaWxkcmVuSWRzIjpbXSwic3lzdGVtVHlwZSI6Ik5PUk1BTCIsInN5c3RlbVBheW1lbnRUeXBlIjoiREVCSVQiLCJzeXN0ZW1OYW1lIjoic29iYW4iLCJzZW5kV2l0aE91dFBlcm1pc3Npb25TeXN0ZW0iOmZhbHNlLCJtaXNDdXN0b21lciI6Itio2YbYr9in2LEg2LPZiNio2KfZhiDYs9in2YXYp9mG2YciLCJwYXJlbnRVc2VyTmFtZSI6bnVsbCwicGFyZW50SWQiOm51bGwsImFkbWluIjp0cnVlfSwiYXV0aG9yaXRpZXMiOlsiVVNFUiJdLCJqdGkiOiJNeU5vTml2RURmc3YxUDR3U20tbTR2Y2NHODgiLCJjbGllbnRfaWQiOiJzb2JhbiJ9.9mdneDduK4OVH2zsXvRqvXt2qwpLvs0vCuseLaB-LCo'
access_token = get_access_token()
body = [
{
"sender": "98200000443295",
"recipient": phone_number,
"body": message,
# "customerId": "1",
# "sendDate": "2024-03-04 10:17:00"
}
]
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
'Accept': 'application/json',
}
response_object = requests.post(url, headers=headers, json=body, timeout=10)
print(response_object.text)
except Exception as e:
print(e)
# send_sms("989106853582", "تست")

View file

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

View file

@ -0,0 +1,43 @@
import urllib.parse
import requests
from django.conf import settings
import logging
from utils.clients.sms.sms import BaseSMSClient
logger = logging.getLogger(__name__)
class MobinSMSClient(BaseSMSClient):
access_token = settings.MOBIN_SMS_TOKEN
base_url = 'https://connect.mobinsms.com'
sender = '9890008050'
def send_sms(self, phone_number, message):
return self.send_sms_quick([phone_number], message)
def send_sms_quick(self, phone_number_list, message):
try:
path = '/v1/send/quick'
url = urllib.parse.urljoin(self.base_url, path)
body = {
"from": self.sender,
"to": phone_number_list,
"body": message,
"unique_id": "" # UDH (optional field)
}
headers = {
"X-API-Key": self.access_token,
'Content-Type': 'application/json',
'Accept': 'application/json',
}
response = requests.post(url, headers=headers, json=body, timeout=10)
logger.info(response.text)
return response.json()
except Exception as e:
logger.exception(f'error sending sms to {phone_number_list}: {e}')

View file

@ -0,0 +1,387 @@
{
"info": {
"_postman_id": "9cc27226-bee8-4b29-a924-a9e14bb61d14",
"name": "Connect API",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
"_exporter_id": "2960438"
},
"item": [
{
"name": "Send by Pattern",
"protocolProfileBehavior": {
"followRedirects": true,
"disableUrlEncoding": false,
"disableCookies": false
},
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-API-Key",
"value": "{{apiKey}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n\t\"from\": \"989000xxxx\",\n\t\"to\": [\"989126880345\"],\n\t\"pattern\": \"YOUR_PATTERN_KEY\",\n\t\"data\": {\n\t\t\"0\": \"Var 0\",\n\t\t\"1\": \"Var 1\",\n\t\t\"2\": \"Var 2\",\n\t\t\"3\": \"Var 3\"\n\t}\n}\n",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/v1/send/pattern",
"host": [
"{{baseURL}}"
],
"path": [
"v1",
"send",
"pattern"
]
}
},
"response": []
},
{
"name": "Send Quick",
"protocolProfileBehavior": {
"followRedirects": true,
"disableUrlEncoding": false,
"disableCookies": false
},
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-API-Key",
"value": "{{apiKey}}"
}
],
"body": {
"mode": "raw",
"raw": "{\n\t\"from\": \"989000xxxx\",\n\t\"to\": [\"989126880345\", \"989127761851\"],\n\t\"body\": \"Your message\",\n \"unique_id\": \"\" // UDH (optional field)\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/v1/send/quick",
"host": [
"{{baseURL}}"
],
"path": [
"v1",
"send",
"quick"
]
}
},
"response": []
},
{
"name": "Get Status",
"protocolProfileBehavior": {
"disableBodyPruning": true,
"followRedirects": true,
"disableUrlEncoding": false,
"disableCookies": false
},
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-API-Key",
"value": "{{apiKey}}"
}
],
"body": {
"mode": "raw",
"raw": "",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/v1/message/status?message_id=1893",
"host": [
"{{baseURL}}"
],
"path": [
"v1",
"message",
"status"
],
"query": [
{
"key": "unique_id",
"value": "20",
"disabled": true
},
{
"key": "message_id",
"value": "1893"
}
]
}
},
"response": [
{
"name": "Delivered Message",
"originalRequest": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "User-Agent",
"value": "insomnia/11.6.2"
},
{
"key": "X-API-Key",
"value": "07e7e9c79652b857aa77b8f3d40cc4314321970e1a5f78b9fc18e497aca2bf40"
}
],
"body": {
"mode": "raw",
"raw": "{\n\t\"from\": \"9890001777\",\n\t\"to\": [\"989126880345\"],\n\t\"body\": \"Your message\",\n \"unique_id\": \"6\" // UDH (optional field)\n}\n",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/v1/message/status?message_id=1852",
"host": [
"{{baseURL}}"
],
"path": [
"v1",
"message",
"status"
],
"query": [
{
"key": "message_id",
"value": "1852"
},
{
"key": "unique_id",
"value": "webengage-message-id",
"disabled": true
}
]
}
},
"status": "OK",
"code": 200,
"_postman_previewlanguage": "json",
"header": [
{
"key": "Server",
"value": "nginx"
},
{
"key": "Date",
"value": "Sun, 09 Nov 2025 16:20:37 GMT"
},
{
"key": "Content-Type",
"value": "application/json",
"description": "",
"type": "text"
},
{
"key": "Content-Length",
"value": "38"
},
{
"key": "Connection",
"value": "keep-alive"
}
],
"cookie": [],
"body": "{\n \"id\": 274,\n \"status\": \"2\", // 0: Pending, 1: Sent, 2: Delivered\n \"unique_id\": \"\"\n}"
}
]
},
{
"name": "Webengage",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
},
{
"key": "X-API-Key",
"value": "{{apiKey}}",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"version\": \"2.0\",\n \"smsData\": {\n \"toNumber\": \"989195712939\",\n \"fromNumber\": \"9890006950\",\n \"body\": \"Text message body\"\n },\n \"metadata\": {\n \"campaignType\": \"PROMOTIONAL\",\n \"timestamp\": \"2018-01-25T10:24:16+0000\",\n \"messageId\": \"webengage-message-id222\",\n \"custom\": {\n \"key1\": \"val1\",\n \"key2\": \"val2\"\n },\n \"indiaDLT\": {\n \"contentTemplateId\": \"xyz\",\n \"principalEntityId\": \"abc\",\n \"telemarketerId\": \"tm1,tm2\"\n }\n }\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/v1/integration/webengage",
"host": [
"{{baseURL}}"
],
"path": [
"v1",
"integration",
"webengage"
]
}
},
"response": []
},
{
"name": "Send Bulk",
"request": {
"method": "POST",
"header": [
{
"key": "Content-Type",
"value": "application/json",
"type": "text"
},
{
"key": "X-API-Key",
"value": "{{apiKey}}",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"from\": \"989000xxxx\",\n \"on_duplicate\": \"skip\", // Skip to send only unique ones, reject to stop sending any message even if one is not unique\n \"messages\": [\n {\n \"to\": \"989126880345\",\n \"body\": \"Your message\",\n \"unique_id\": \"1\" // UDH: Optional\n },\n {\n \"to\": \"989127865454\",\n \"body\": \"Your message 2\",\n \"unique_id\": \"20\" // UDH: Optional\n }\n ]\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/v1/send/bulk",
"host": [
"{{baseURL}}"
],
"path": [
"v1",
"send",
"bulk"
]
}
},
"response": []
},
{
"name": "Account Balance",
"request": {
"method": "GET",
"header": [
{
"key": "Content-Type",
"value": "application/json"
},
{
"key": "X-API-Key",
"value": "{{apiKey}}"
}
],
"url": {
"raw": "{{baseURL}}/v1/account/balance",
"host": [
"{{baseURL}}"
],
"path": [
"v1",
"account",
"balance"
],
}
},
"response": []
},
{
"name": "Send vOTP",
"request": {
"method": "POST",
"header": [
{
"key": "x-api-key",
"value": "{{apiKey}}",
"type": "text"
},
{
"key": "content-type",
"value": "application/json",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n\t\"from\": \"9890002999\",\n\t\"to\": \"989126880345\",\n\t\"code\": \"123456\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{baseURL}}/v1/send/votp",
"host": [
"{{baseURL}}"
],
"path": [
"v1",
"send",
"votp"
]
}
},
"response": []
}
],
"variable": [
{
"key": "baseURL",
"value": "",
"type": "default"
},
{
"key": "apiKey",
"value": "",
"type": "default"
}
]
}

View file

@ -0,0 +1,54 @@
# TODO: this is time fourced code. refactor
import requests
from django.conf import settings
import logging
from utils.clients.sms.sms import BaseSMSClient
logger = logging.getLogger(__name__)
class PayamSMSClient(BaseSMSClient):
def get_access_token(self):
payam_sms_system_name = settings.PAYAM_SMS_SYSTEM_NAME
payam_sms_username = settings.PAYAM_SMS_USERNAME
payam_sms_password = settings.PAYAM_SMS_PASSWORD
payam_sms_client_id = settings.PAYAM_SMS_CLIENT_ID
payam_sms_client_secret = settings.PAYAM_SMS_CLIENT_SECRET
auth = (payam_sms_client_id, payam_sms_client_secret)
url = f"https://www.payamsms.com/auth/oauth/token?systemName={payam_sms_system_name}&username={payam_sms_username}&password={payam_sms_password}&scope=webservice&grant_type=password"
response_object = requests.post(url, auth=auth, json={})
response = response_object.json()
return response["access_token"]
def send_sms(self, phone_number, message):
try:
url = "https://www.payamsms.com/panel/webservice/send"
# access_token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJzb2JhbiIsInNjb3BlIjpbIndlYnNlcnZpY2UiXSwiZXhwIjoxNzQ0MTAyMDU5LCJ1c2VyRGV0YWlscyI6eyJpZCI6MjU0MCwidXNlck5hbWUiOiJzb2JhbiIsInJvbGUiOiJVU0VSIiwiZW5hYmxlZCI6dHJ1ZSwic3lzdGVtSWQiOjcyMiwiYWNjZXNzIjp7InJvbGUiOlsiYWRkIiwiZWRpdCIsImdldCIsImRlbGV0ZSJdLCJwaG9uZUJvb2siOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInNtcyI6WyJzaW5nbGUiLCJnZXRMb2NhbCIsImdldEdsb2JhbCIsImJ1bGsiLCJkYiJdLCJyZXBvcnQiOlsiZ2V0TW9HbG9iYWwiLCJnZXREYWlseUxvY2FsIiwiZ2V0TW9Mb2NhbCIsImdldERhaWx5R2xvYmFsIl0sIndzIjpbIndzIl0sInVzZXIiOlsiYWRkIiwiZWRpdCIsImdldExvY2FsIiwiZ2V0IiwiZ2V0R2xvYmFsIiwiZGVsZXRlIl0sInRvb2xzIjpbInJhY2UiLCJwZXJpb2RpYyIsInBvbGxpbmciLCJzZWNyZXRvcnkiLCJhdXRvYW5zd2VyIl0sImZpbmFuY2UiOlsidGRyIiwiY2hhcmdlIl19LCJyb2xlSWQiOjQ0NDAsImNoaWxkcmVuSWRzIjpbXSwic3lzdGVtVHlwZSI6Ik5PUk1BTCIsInN5c3RlbVBheW1lbnRUeXBlIjoiREVCSVQiLCJzeXN0ZW1OYW1lIjoic29iYW4iLCJzZW5kV2l0aE91dFBlcm1pc3Npb25TeXN0ZW0iOmZhbHNlLCJtaXNDdXN0b21lciI6Itio2YbYr9in2LEg2LPZiNio2KfZhiDYs9in2YXYp9mG2YciLCJwYXJlbnRVc2VyTmFtZSI6bnVsbCwicGFyZW50SWQiOm51bGwsImFkbWluIjp0cnVlfSwiYXV0aG9yaXRpZXMiOlsiVVNFUiJdLCJqdGkiOiJNeU5vTml2RURmc3YxUDR3U20tbTR2Y2NHODgiLCJjbGllbnRfaWQiOiJzb2JhbiJ9.9mdneDduK4OVH2zsXvRqvXt2qwpLvs0vCuseLaB-LCo'
access_token = self.get_access_token()
body = [
{
"sender": "98200000443295",
"recipient": phone_number,
"body": message,
# "customerId": "1",
# "sendDate": "2024-03-04 10:17:00"
}
]
headers = {
'Authorization': f'Bearer {access_token}',
'Content-Type': 'application/json',
'Accept': 'application/json',
}
response_object = requests.post(url, headers=headers, json=body, timeout=10)
logger.info(response_object.text)
return response_object
except Exception as e:
logger.exception(f'error sending sms to {phone_number}: {e}')

Some files were not shown because too many files have changed in this diff Show more