application gateway policy

This commit is contained in:
Sayyid Hamid Mahdavi 2026-04-22 19:51:36 +03:30
parent 82e35a98f7
commit 556cdaf20a
10 changed files with 356 additions and 95 deletions

View file

@ -23,6 +23,14 @@ class BasePGModel(models.Model):
redirect_method = 'GET' redirect_method = 'GET'
@property
def label(self):
raise NotImplementedError()
@property
def image(self):
raise NotImplementedError()
def get_pg_page_url(self): def get_pg_page_url(self):
pass pass

View file

@ -12,7 +12,8 @@ from django.shortcuts import redirect, get_object_or_404
from django.urls import path from django.urls import path
from .constans import StateChoices from .constans import StateChoices
from .models import PaymentRequest, PNA, Fake from .forms import ApplicationGatewayPolicyForm
from .models import PaymentRequest, PNA, Fake, ApplicationGatewayPolicy
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -112,6 +113,12 @@ class PaymentRequestAdmin(admin.ModelAdmin):
class FakeAdmin(admin.ModelAdmin): class FakeAdmin(admin.ModelAdmin):
list_display = [ 'created_at'] list_display = [ 'created_at']
class ApplicationGatewayPolicyAdmin(admin.ModelAdmin):
form = ApplicationGatewayPolicyForm
admin.site.register(PaymentRequest, PaymentRequestAdmin) admin.site.register(PaymentRequest, PaymentRequestAdmin)
admin.site.register(PNA, PNAAdmin) admin.site.register(PNA, PNAAdmin)
admin.site.register(Fake, FakeAdmin) admin.site.register(Fake, FakeAdmin)
admin.site.register(ApplicationGatewayPolicy, ApplicationGatewayPolicyAdmin)

View file

@ -1,6 +1,6 @@
from django import forms from django import forms
from .models import PaymentRequest from .models import PaymentRequest, GatewayChoise
class PaymentRequestForm(forms.ModelForm): class PaymentRequestForm(forms.ModelForm):
@ -8,3 +8,44 @@ class PaymentRequestForm(forms.ModelForm):
class Meta: class Meta:
model = PaymentRequest model = PaymentRequest
fields = [] fields = []
class DelimitedListField(forms.MultipleChoiceField):
"""
Django form field that allows for the use of list widgets with a text field
containing a delimited list.
"""
delimiter = ','
def __init__(self, delimiter=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.delimiter = delimiter or self.delimiter
def prepare_value(self, value):
# If the value is already a list or tuple, just use it as-is
if isinstance(value, (list, tuple)): return value
# Otherwise, prepare the value by splitting on the delimiter, trimming
# leading and trailing whitespace and excluding empty values
return [p.strip() for p in value.split(self.delimiter) if p.strip()]
def clean(self, value):
# Let the parent clean the value first, then join the result using the
# specified delimiter
return self.delimiter.join(super().clean(value))
class ApplicationGatewayPolicyForm(forms.ModelForm):
"""
Form for creating or updating a restricted application.
"""
# allowed_scope is a space-delimited list, but we want to present
# a selection of valid scopes with checkboxes
preferred_gateways = DelimitedListField(
label='preferred gateways',
# The choices and initial values are callables, because the scopes might
# not be available at import type, e.g. if coming from the database
choices=GatewayChoise.choices,
initial=GatewayChoise.choices,
delimiter=' ',
widget=forms.CheckboxSelectMultiple
)

View file

@ -0,0 +1,36 @@
# Generated by Django 5.1.4 on 2026-04-22 19:48
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pg', '0009_remove_sep_sep_order_id_remove_sep_sep_originator_and_more'),
migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
]
operations = [
migrations.AlterField(
model_name='paymentrequest',
name='gateway',
field=models.CharField(blank=True, choices=[('fake', 'fake ipg'), ('pna', 'pna ipg'), ('sep', 'sep ipg')], max_length=255),
),
migrations.CreateModel(
name='ApplicationGatewayPolicy',
fields=[
('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, 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_gateways', models.CharField(blank=True, default='', max_length=100, null=True)),
('policy', models.CharField(choices=[('random', 'random'), ('selectable', 'selectable')], default='selectable', max_length=20)),
('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

@ -22,18 +22,22 @@ from utils.pna import PNAClient
from utils.sep import SEPClient from utils.sep import SEPClient
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class GatewayChoise(TextChoices): class GatewayChoise(TextChoices):
FAKE = "fake", _("fake") # FAKE = "fake", _("fake ipg")
PNA = 'pna', _('pna') PNA = 'pna', _('pna ipg')
SEP = 'sep', _('sep') SEP = 'sep', _('sep ipg')
class GatewayPolicy(BasePGModel): def get_gateway_class(gateway_label):
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, return {
related_name='+', null=True, blank=True, unique=True) # "fake": Fake,
preference_gateway = models.JSONField(default=list, blank=True) "pna": PNA,
"sep": SEP,
}[gateway_label]
# TODO: use generic relation # TODO: use generic relation
@ -44,7 +48,7 @@ class PaymentRequest(models.Model):
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT,
related_name='+', null=True, blank=True) related_name='+', null=True, blank=True)
gateway = models.CharField(max_length=255, choices=GatewayChoise.choices) gateway = models.CharField(max_length=255, choices=GatewayChoise.choices, blank=True)
state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED) state = models.IntegerField(choices=StateChoices.choices, default=StateChoices.CREATED)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT) user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
@ -83,38 +87,36 @@ class PaymentRequest(models.Model):
self.commission = calculate_pna_commission(self.amount) self.commission = calculate_pna_commission(self.amount)
self.save(update_fields=['commission']) self.save(update_fields=['commission'])
def get_policy(self):
application_gateway, created = ApplicationGatewayPolicy.objects.get_or_create(application=self.application)
return application_gateway
def get_preferred_gateways(self):
application_policy = self.get_policy()
return application_policy.get_preferred_gateways()
def get_preferred_gateway_classes(self):
preferred_gateways = self.get_preferred_gateways()
return [get_gateway_class(gateway_label) for gateway_label in preferred_gateways]
def get_gateway_tools(self): def get_gateway_tools(self):
if self.gateway == GatewayChoise.PNA: gateway_class = get_gateway_class(self.gateway)
return PNA.objects.get(payment_request=self) return gateway_class.objects.get(payment_request=self)
if self.gateway == GatewayChoise.SEP:
return SEP.objects.get(payment_request=self)
if self.gateway == GatewayChoise.FAKE:
return Fake.objects.get(payment_request=self)
def set_gateway(self, data): def set_gateway(self, data):
preferred_gateways = self.get_preferred_gateways()
for gateway_label in preferred_gateways:
if gateway_label in data:
self.gateway = gateway_label
gateway_class = get_gateway_class(gateway_label)
pg = gateway_class.objects.create(payment_request=self)
pg.payment_request = self
# if 'fake' in data: self.save()
# self.gateway = GatewayChoise.FAKE
# fake = Fake.objects.create(payment_request=self)
# fake.payment_request = self
#
if 'pna' in data: if not self.gateway:
self.gateway = GatewayChoise.PNA raise Exception('No gateway')
pg = PNA.objects.create(payment_request=self)
pg.payment_request = self
elif 'sep' in data:
self.gateway = GatewayChoise.SEP
pg = SEP.objects.create(payment_request=self)
pg.payment_request = self
else:
raise Exception('Unknown gateway')
self.save()
@property @property
def reference_number(self): def reference_number(self):
@ -181,7 +183,8 @@ class PaymentRequest(models.Model):
raise Exception('Failed to get response uuid from wallet service') raise Exception('Failed to get response uuid from wallet service')
if verify_response.state != 5: if verify_response.state != 5:
logger.error(f"[Deposit Verify] Wallet verification failed | payment_id={self.pk}, response={verify_response}") logger.error(
f"[Deposit Verify] Wallet verification failed | payment_id={self.pk}, response={verify_response}")
# to_pay_failed = PaymentRequest.objects.filter( # to_pay_failed = PaymentRequest.objects.filter(
# pk=self.pk, # pk=self.pk,
# state=StateChoices.WALLET_SUBMIT_SUCCESS.value # state=StateChoices.WALLET_SUBMIT_SUCCESS.value
@ -225,6 +228,8 @@ class PaymentRequest(models.Model):
class PNA(BasePGModel): class PNA(BasePGModel):
label = GatewayChoise.PNA.value
image = f'image/{GatewayChoise.PNA.value}.png'
payment_request = models.OneToOneField(PaymentRequest, on_delete=models.PROTECT, related_name='pna') payment_request = models.OneToOneField(PaymentRequest, on_delete=models.PROTECT, related_name='pna')
details = models.JSONField(null=True, blank=True, default=dict) details = models.JSONField(null=True, blank=True, default=dict)
@ -248,7 +253,6 @@ class PNA(BasePGModel):
return to_jalali(self.created_at) return to_jalali(self.created_at)
def make_request(self): def make_request(self):
self.payment_request.change_state(StateChoices.CREATED, StateChoices.IPG_PENDING, 'can not change state') self.payment_request.change_state(StateChoices.CREATED, StateChoices.IPG_PENDING, 'can not change state')
amount = self.payment_request.amount + self.payment_request.commission amount = self.payment_request.amount + self.payment_request.commission
@ -271,7 +275,8 @@ class PNA(BasePGModel):
self.payment_request.change_state(self.payment_request.state, StateChoices.FAILED, self.payment_request.change_state(self.payment_request.state, StateChoices.FAILED,
'error in pardakht novin gateway') 'error in pardakht novin gateway')
except Exception as e: except Exception as e:
self.payment_request.change_state(self.payment_request.state, StateChoices.FAILED, 'error in pardakht novin gateway') self.payment_request.change_state(self.payment_request.state, StateChoices.FAILED,
'error in pardakht novin gateway')
self.save() self.save()
@ -322,6 +327,9 @@ class PNA(BasePGModel):
class SEP(BasePGModel): class SEP(BasePGModel):
label = GatewayChoise.SEP.value
image = f'image/{GatewayChoise.SEP.value}.png'
redirect_method = 'GET' redirect_method = 'GET'
payment_request = models.OneToOneField(PaymentRequest, on_delete=models.PROTECT, related_name='sep') payment_request = models.OneToOneField(PaymentRequest, on_delete=models.PROTECT, related_name='sep')
@ -421,6 +429,9 @@ class SEP(BasePGModel):
class Fake(BasePGModel): class Fake(BasePGModel):
label = GatewayChoise.FAKE.value
image = f'image/{GatewayChoise.FAKE.value}.png'
payment_request = models.OneToOneField(PaymentRequest, on_delete=models.PROTECT, related_name='+') payment_request = models.OneToOneField(PaymentRequest, on_delete=models.PROTECT, related_name='+')
def make_request(self): def make_request(self):
@ -438,3 +449,17 @@ class Fake(BasePGModel):
def reverse_request(self): def reverse_request(self):
raise NotImplementedError() raise NotImplementedError()
class ApplicationGatewayPolicy(BaseModel):
class PolicyChoise(TextChoices):
RANDOM = "random", _("random")
SELECTABLE = 'selectable', _('selectable')
application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT,
related_name='+', null=True, blank=True, unique=True)
preferred_gateways = models.CharField(max_length=100, null=True, blank=True, default='')
policy = models.CharField(max_length=20, choices=PolicyChoise.choices, default=PolicyChoise.SELECTABLE)
def get_preferred_gateways(self):
return [p.strip() for p in self.preferred_gateways.split() if p.strip()]

View file

@ -74,16 +74,56 @@ class PaymentRequestView(UpdateView):
slug_field = 'uuid' slug_field = 'uuid'
slug_url_kwarg = 'uuid' slug_url_kwarg = 'uuid'
# TODO: remove it in future
# def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
# charge_request: PaymentRequest = self.get_object() charge_request: PaymentRequest = self.get_object()
# if charge_request.state == StateChoices.FAILED: # TODO: check more states
# return render(request, 'pg/payment_failure.html', { # TODO: prevent double
# 'object': charge_request if charge_request.state == StateChoices.FAILED:
# }) return render(request, 'pg/payment_failure.html', {
# redirect_url = tool.get_pg_page_url() 'object': charge_request
# return HttpResponseRedirect(redirect_url) })
#
# # gateway was setted in api call
# # TODO: double check
# if charge_request.gateway and not charge_request.commission:
# charge_request.save_commission()
# gateway_tool = charge_request.get_gateway_tools()
# gateway_tool.make_request()
# return HttpResponseRedirect(gateway_tool.redirect_url)
application_policy = charge_request.get_policy()
preferred_gateways = application_policy.get_preferred_gateways()
if preferred_gateways:
if application_policy.policy == application_policy.PolicyChoise.RANDOM:
# TODO: move to model
import random
gateway = random.choices(preferred_gateways)
charge_request.set_gateway(gateway)
charge_request.save_commission()
gateway_tool = charge_request.get_gateway_tools()
gateway_tool.make_request()
redirect_url = gateway_tool.get_pg_page_url()
return HttpResponseRedirect(redirect_url)
elif len(preferred_gateways) == 1:
gateway = preferred_gateways[0]
charge_request.set_gateway(gateway)
charge_request.save_commission()
gateway_tool = charge_request.get_gateway_tools()
gateway_tool.make_request()
redirect_url = gateway_tool.get_pg_page_url()
return HttpResponseRedirect(redirect_url)
else:
return super().get(request, *args, **kwargs)
return render(request, 'pg/payment_failure.html', {
'object': charge_request
})
# # TODO: remove it in future # # TODO: remove it in future
# def post(self, request, *args, **kwargs): # def post(self, request, *args, **kwargs):
# charge_request: PaymentRequest = self.get_object() # charge_request: PaymentRequest = self.get_object()

View file

View file

@ -0,0 +1,142 @@
import os
def get_settings(key):
from django.conf import settings
return getattr(settings, 'BASE_DIR')
def get_logging_setting():
BASE_DIR = get_settings('BASE_DIR')
LOG_DIR = BASE_DIR / 'log'
if not os.path.exists(LOG_DIR):
os.makedirs(LOG_DIR)
return {
'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

@ -257,40 +257,8 @@ CACHES = {
LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str) LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str)
LOGGING = { from main.other_settings.logging import get_logging_setting
'version': 1, LOGGING = get_logging_setting()
'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": "ipg"}, # 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,
},
},
}
PNA_CORPORATION_PIN = config('PNA_CORPORATION_PIN') PNA_CORPORATION_PIN = config('PNA_CORPORATION_PIN')
WALLET_BASE_PUBLIC_URL = config('WALLET_BASE_PUBLIC_URL') WALLET_BASE_PUBLIC_URL = config('WALLET_BASE_PUBLIC_URL')

View file

@ -22,17 +22,11 @@
<h6 class="h6">پرداخت با:</h6> <h6 class="h6">پرداخت با:</h6>
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col"> <div class="col">
{# <div class="">#} {% for gateway_tool in object.get_preferred_gateway_classes %}
<button type="submit" class="btn mx-auto center-block" name="pna"> <button type="submit" class="btn mx-auto center-block" name="{{ gateway_tool.label }}">
<img class="img-thumbnail" width="128" height="128" src="{% static "image/pna.png" %}"/> <img class="img-thumbnail" width="128" height="128" src="{% static gateway_tool.image %}"/>
</button> </button>
{# <button type="submit" class="btn mx-auto center-block" name="sep">#} {% endfor %}
{# <img class="img-thumbnail" width="128" height="128" src="{% static "image/sep.png" %}"/>#}
{# </button>#}
{# <button type="submit" class="btn mx-auto center-block" name="fake">#}
{# <img class="img-thumbnail" width="128" height="128" src="{% static "image/fake.png" %}"/>#}
{# </button>#}
</div>
</div> </div>
</div> </div>
</div> </div>