diff --git a/apps/core/models.py b/apps/core/models.py index 8b14c79..dd19bf8 100644 --- a/apps/core/models.py +++ b/apps/core/models.py @@ -23,6 +23,14 @@ class BasePGModel(models.Model): redirect_method = 'GET' + @property + def label(self): + raise NotImplementedError() + + @property + def image(self): + raise NotImplementedError() + def get_pg_page_url(self): pass diff --git a/apps/pg/admin.py b/apps/pg/admin.py index 1dc9b08..906e450 100644 --- a/apps/pg/admin.py +++ b/apps/pg/admin.py @@ -12,7 +12,8 @@ from django.shortcuts import redirect, get_object_or_404 from django.urls import path 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__) @@ -112,6 +113,12 @@ class PaymentRequestAdmin(admin.ModelAdmin): class FakeAdmin(admin.ModelAdmin): list_display = [ 'created_at'] + +class ApplicationGatewayPolicyAdmin(admin.ModelAdmin): + form = ApplicationGatewayPolicyForm + + admin.site.register(PaymentRequest, PaymentRequestAdmin) admin.site.register(PNA, PNAAdmin) -admin.site.register(Fake, FakeAdmin) \ No newline at end of file +admin.site.register(Fake, FakeAdmin) +admin.site.register(ApplicationGatewayPolicy, ApplicationGatewayPolicyAdmin) \ No newline at end of file diff --git a/apps/pg/forms.py b/apps/pg/forms.py index ca27cd3..6705a88 100644 --- a/apps/pg/forms.py +++ b/apps/pg/forms.py @@ -1,6 +1,6 @@ from django import forms -from .models import PaymentRequest +from .models import PaymentRequest, GatewayChoise class PaymentRequestForm(forms.ModelForm): @@ -8,3 +8,44 @@ class PaymentRequestForm(forms.ModelForm): class Meta: model = PaymentRequest 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 + ) diff --git a/apps/pg/migrations/0010_alter_paymentrequest_gateway_and_more.py b/apps/pg/migrations/0010_alter_paymentrequest_gateway_and_more.py new file mode 100644 index 0000000..e819477 --- /dev/null +++ b/apps/pg/migrations/0010_alter_paymentrequest_gateway_and_more.py @@ -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, + }, + ), + ] diff --git a/apps/pg/models.py b/apps/pg/models.py index 2f636bd..b832085 100644 --- a/apps/pg/models.py +++ b/apps/pg/models.py @@ -22,18 +22,22 @@ from utils.pna import PNAClient from utils.sep import SEPClient from django.utils.translation import gettext_lazy as _ + logger = logging.getLogger(__name__) + class GatewayChoise(TextChoices): - FAKE = "fake", _("fake") - PNA = 'pna', _('pna') - SEP = 'sep', _('sep') +# FAKE = "fake", _("fake ipg") + PNA = 'pna', _('pna ipg') + SEP = 'sep', _('sep ipg') -class GatewayPolicy(BasePGModel): - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, - related_name='+', null=True, blank=True, unique=True) - preference_gateway = models.JSONField(default=list, blank=True) +def get_gateway_class(gateway_label): + return { + # "fake": Fake, + "pna": PNA, + "sep": SEP, + }[gateway_label] # TODO: use generic relation @@ -44,7 +48,7 @@ class PaymentRequest(models.Model): application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.PROTECT, 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) 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.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): - if self.gateway == GatewayChoise.PNA: - return PNA.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) + gateway_class = get_gateway_class(self.gateway) + return gateway_class.objects.get(payment_request=self) 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.gateway = GatewayChoise.FAKE - # fake = Fake.objects.create(payment_request=self) - # fake.payment_request = self - # + self.save() - if 'pna' in data: - self.gateway = GatewayChoise.PNA - 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() + if not self.gateway: + raise Exception('No gateway') @property def reference_number(self): @@ -181,7 +183,8 @@ class PaymentRequest(models.Model): raise Exception('Failed to get response uuid from wallet service') 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( # pk=self.pk, # state=StateChoices.WALLET_SUBMIT_SUCCESS.value @@ -225,6 +228,8 @@ class PaymentRequest(models.Model): 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') details = models.JSONField(null=True, blank=True, default=dict) @@ -248,7 +253,6 @@ class PNA(BasePGModel): return to_jalali(self.created_at) - def make_request(self): self.payment_request.change_state(StateChoices.CREATED, StateChoices.IPG_PENDING, 'can not change state') 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, 'error in pardakht novin gateway') 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() @@ -322,6 +327,9 @@ class PNA(BasePGModel): class SEP(BasePGModel): + label = GatewayChoise.SEP.value + image = f'image/{GatewayChoise.SEP.value}.png' + redirect_method = 'GET' payment_request = models.OneToOneField(PaymentRequest, on_delete=models.PROTECT, related_name='sep') @@ -421,6 +429,9 @@ class SEP(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='+') def make_request(self): @@ -438,3 +449,17 @@ class Fake(BasePGModel): def reverse_request(self): 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()] diff --git a/apps/pg/views.py b/apps/pg/views.py index 96ffefa..a8c6a72 100644 --- a/apps/pg/views.py +++ b/apps/pg/views.py @@ -74,16 +74,56 @@ class PaymentRequestView(UpdateView): slug_field = 'uuid' slug_url_kwarg = 'uuid' - # TODO: remove it in future - # def get(self, request, *args, **kwargs): - # charge_request: PaymentRequest = self.get_object() - # if charge_request.state == StateChoices.FAILED: - # return render(request, 'pg/payment_failure.html', { - # 'object': charge_request - # }) - # redirect_url = tool.get_pg_page_url() - # return HttpResponseRedirect(redirect_url) - # + + def get(self, request, *args, **kwargs): + charge_request: PaymentRequest = self.get_object() + # TODO: check more states + # TODO: prevent double + if charge_request.state == StateChoices.FAILED: + return render(request, 'pg/payment_failure.html', { + 'object': charge_request + }) + + # # 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 # def post(self, request, *args, **kwargs): # charge_request: PaymentRequest = self.get_object() diff --git a/main/other_settings/__init__.py b/main/other_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main/other_settings/logging.py b/main/other_settings/logging.py new file mode 100644 index 0000000..2a2e654 --- /dev/null +++ b/main/other_settings/logging.py @@ -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, +# }, +# }, +# } \ No newline at end of file diff --git a/main/settings.py b/main/settings.py index 1c406a3..ef8008e 100644 --- a/main/settings.py +++ b/main/settings.py @@ -257,40 +257,8 @@ CACHES = { LOKI_BASE_PUBLIC_URL = config('LOKI_BASE_PUBLIC_URL', default=None, cast=str) -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'loki': { - 'class': 'utils.logs.LokiFormatter', # required - }, - }, - - 'handlers': { - 'loki': { - 'level': 'DEBUG', # Log level. Required - 'class': 'utils.logs.LokiHandler', # Required - 'formatter': 'loki', # Loki formatter. Required - 'timeout': 2, # Post request timeout, default is 0.5. Optional - 'url': f'{LOKI_BASE_PUBLIC_URL}/loki/api/v1/push', # Loki url. Defaults to localhost. Optional. - # 'auth': ("user", "password"), # Basic auth to authenticate with loki. Default is None (i.e. no auth). Optional - 'tags': {"app": "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, - }, - }, -} +from main.other_settings.logging import get_logging_setting +LOGGING = get_logging_setting() PNA_CORPORATION_PIN = config('PNA_CORPORATION_PIN') WALLET_BASE_PUBLIC_URL = config('WALLET_BASE_PUBLIC_URL') diff --git a/templates/pg/payment_request.html b/templates/pg/payment_request.html index 364f4f3..9b08044 100644 --- a/templates/pg/payment_request.html +++ b/templates/pg/payment_request.html @@ -22,17 +22,11 @@