From d596e319f0c6262fd7c45da1c6536bd6c97cd114 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Sun, 10 May 2026 15:25:49 +0330 Subject: [PATCH] init --- .gitignore | 7 + Dockerfile | 11 + apps/core/__init__.py | 0 apps/core/admin.py | 12 + apps/core/apps.py | 6 + apps/core/decorators.py | 25 ++ apps/core/migrations/0001_initial.py | 31 +++ apps/core/migrations/__init__.py | 0 apps/core/models.py | 80 ++++++ apps/core/tests.py | 3 + apps/core/urls.py | 7 + apps/core/views.py | 41 +++ apps/gooyal_oauth2/__init__.py | 0 apps/gooyal_oauth2/admin.py | 13 + apps/gooyal_oauth2/apps.py | 6 + apps/gooyal_oauth2/forms.py | 9 + apps/gooyal_oauth2/migrations/0001_initial.py | 103 ++++++++ apps/gooyal_oauth2/migrations/0002_initial.py | 83 ++++++ ...er_application_authorization_grant_type.py | 18 ++ apps/gooyal_oauth2/migrations/__init__.py | 0 apps/gooyal_oauth2/models.py | 45 ++++ apps/gooyal_oauth2/rest_framework.py | 19 ++ apps/gooyal_oauth2/utils.py | 6 + apps/gooyal_oauth2/validators.py | 170 +++++++++++++ apps/users/__init__.py | 0 apps/users/admin.py | 5 + apps/users/apps.py | 6 + apps/users/migrations/0001_initial.py | 46 ++++ apps/users/migrations/__init__.py | 0 apps/users/models.py | 71 ++++++ apps/users/tests.py | 3 + apps/users/views.py | 3 + main/__init__.py | 0 main/asgi.py | 16 ++ main/other_settings/__init__.py | 0 main/other_settings/logging.py | 142 +++++++++++ main/settings.py | 239 ++++++++++++++++++ main/urls.py | 43 ++++ main/wsgi.py | 16 ++ manage.py | 22 ++ requirements.in | 22 ++ requirements.txt | 184 ++++++++++++++ run.sh | 8 + utils/exceptions.py | 100 ++++++++ 44 files changed, 1621 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 apps/core/__init__.py create mode 100644 apps/core/admin.py create mode 100644 apps/core/apps.py create mode 100644 apps/core/decorators.py create mode 100644 apps/core/migrations/0001_initial.py create mode 100644 apps/core/migrations/__init__.py create mode 100644 apps/core/models.py create mode 100644 apps/core/tests.py create mode 100644 apps/core/urls.py create mode 100644 apps/core/views.py create mode 100644 apps/gooyal_oauth2/__init__.py create mode 100755 apps/gooyal_oauth2/admin.py create mode 100755 apps/gooyal_oauth2/apps.py create mode 100644 apps/gooyal_oauth2/forms.py create mode 100644 apps/gooyal_oauth2/migrations/0001_initial.py create mode 100644 apps/gooyal_oauth2/migrations/0002_initial.py create mode 100644 apps/gooyal_oauth2/migrations/0003_alter_application_authorization_grant_type.py create mode 100644 apps/gooyal_oauth2/migrations/__init__.py create mode 100644 apps/gooyal_oauth2/models.py create mode 100644 apps/gooyal_oauth2/rest_framework.py create mode 100644 apps/gooyal_oauth2/utils.py create mode 100755 apps/gooyal_oauth2/validators.py create mode 100644 apps/users/__init__.py create mode 100644 apps/users/admin.py create mode 100644 apps/users/apps.py create mode 100644 apps/users/migrations/0001_initial.py create mode 100644 apps/users/migrations/__init__.py create mode 100644 apps/users/models.py create mode 100644 apps/users/tests.py create mode 100644 apps/users/views.py create mode 100644 main/__init__.py create mode 100644 main/asgi.py create mode 100644 main/other_settings/__init__.py create mode 100644 main/other_settings/logging.py create mode 100644 main/settings.py create mode 100644 main/urls.py create mode 100644 main/wsgi.py create mode 100755 manage.py create mode 100644 requirements.in create mode 100644 requirements.txt create mode 100755 run.sh create mode 100644 utils/exceptions.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c25a548 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.idea +.env +media +/clients/ +/delme.py +/log/accounts.log +/log/errors.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df21b9b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM base:debian_13 +ENV PYTHONUNBUFFERED 1 +WORKDIR /app +#RUN date +RUN apt update +#RUN apt install gnupg2 netcat-traditional libssl-dev libcrypto++-dev lsb-release python3-pip -y +#RUN apt install binutils libproj-dev gdal-bin -y +RUN apt install nano htop iputils-ping -y +#COPY requirements.txt /app/requirements.txt +#RUN pip3 install --break-system-packages -r requirements.txt +#ENTRYPOINT ["./run.sh"] diff --git a/apps/core/__init__.py b/apps/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/admin.py b/apps/core/admin.py new file mode 100644 index 0000000..b6e2a4b --- /dev/null +++ b/apps/core/admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from .models import Config + + +class ConfigAdmin(admin.ModelAdmin): + list_display = ['key', 'value', 'value_type', 'get_value', 'description', 'comment'] + +admin.site.register(Config, ConfigAdmin) + + + + diff --git a/apps/core/apps.py b/apps/core/apps.py new file mode 100644 index 0000000..4143768 --- /dev/null +++ b/apps/core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.core' diff --git a/apps/core/decorators.py b/apps/core/decorators.py new file mode 100644 index 0000000..f8257e8 --- /dev/null +++ b/apps/core/decorators.py @@ -0,0 +1,25 @@ +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(config.description or "این سرویس در حال حاضر در دسترس نیست") + + return view_func(self, request, *args, **kwargs) + + return wrapper + + return with_params diff --git a/apps/core/migrations/0001_initial.py b/apps/core/migrations/0001_initial.py new file mode 100644 index 0000000..96131c5 --- /dev/null +++ b/apps/core/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.4 on 2026-03-29 13:38 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Config', + 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)), + ('key', models.CharField(db_index=True, max_length=255, unique=True, verbose_name='key')), + ('value', models.TextField(blank=True, null=True, verbose_name='value')), + ('value_type', models.CharField(choices=[('INT', 'Integer'), ('FLOAT', 'Float'), ('BOOLEAN', 'Boolean'), ('STRING', 'String'), ('DATE', 'Date'), ('DATETIME', 'DateTime'), ('JSON', 'JSON')], max_length=64)), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('comment', models.TextField(blank=True, null=True, verbose_name='comment')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/core/migrations/__init__.py b/apps/core/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/models.py b/apps/core/models.py new file mode 100644 index 0000000..394e90f --- /dev/null +++ b/apps/core/models.py @@ -0,0 +1,80 @@ +import json +import uuid +from datetime import datetime + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.core.cache import cache + + +class BaseModel(models.Model): + id = None + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) + created_at = models.DateTimeField(auto_now_add=True, db_index=True) + updated_at = models.DateTimeField(auto_now=True, db_index=True) + + class Meta: + abstract = True + + +class ConfigValueChoices(models.TextChoices): + INT = 'INT', _('Integer') + FLOAT = 'FLOAT', _('Float') + BOOLEAN = 'BOOLEAN', _('Boolean') + STRING = 'STRING', _('String') + DATE = 'DATE', _('Date') + DATETIME = 'DATETIME', _('DateTime') + JSON = 'JSON', _('JSON') + + +class Config(BaseModel): + key = models.CharField(_('key'), max_length=255, unique=True, db_index=True) + value = models.TextField(_('value'), null=True, blank=True) + value_type = models.CharField(choices=ConfigValueChoices.choices, max_length=64) + description = models.TextField(_('description'), null=True, blank=True) + comment = models.TextField(_('comment'), null=True, blank=True) + + def get_value(self): + return Config.get_value_of(self.key) + + @staticmethod + def type_cast(value, value_type): + + try: + if value_type == ConfigValueChoices.STRING.value: + value = str(value) + + elif value_type == ConfigValueChoices.BOOLEAN.value: + value = value.lower() == 'true' + + elif value_type == ConfigValueChoices.DATE.value: + value = datetime.fromisoformat(value) + + elif value_type == ConfigValueChoices.DATETIME.value: + value = datetime.fromisoformat(value) + + elif value_type == ConfigValueChoices.JSON.value: + value = json.loads(value) + + elif value_type == ConfigValueChoices.INT.value: + value = int(value) + + elif value_type == ConfigValueChoices.FLOAT.value: + value = float(value) + + except Exception as e: + value = None + + return value + + @staticmethod + def get_value_of(key, default=None, casting_type=None): + # TODO: use cache + config, created = Config.objects.get_or_create(key=key, defaults={'value': default}) + + if config.value is None: + return default + + value = Config.type_cast(config.value, casting_type or config.value_type) + + return value diff --git a/apps/core/tests.py b/apps/core/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/core/urls.py b/apps/core/urls.py new file mode 100644 index 0000000..05942b3 --- /dev/null +++ b/apps/core/urls.py @@ -0,0 +1,7 @@ +from django.urls import path, include +from .views import HomeView +app_name = "core" + +urlpatterns = [ + # path('', HomeView.as_view(), name='home'), +] diff --git a/apps/core/views.py b/apps/core/views.py new file mode 100644 index 0000000..15f5953 --- /dev/null +++ b/apps/core/views.py @@ -0,0 +1,41 @@ +from django.contrib.auth.decorators import login_required +from django.shortcuts import render +from django.utils import timezone +from django.utils.decorators import method_decorator +from django.views.generic import TemplateView +from oauth2_provider.contrib.rest_framework import IsAuthenticatedOrTokenHasScope +from rest_framework import generics +from rest_framework.exceptions import APIException, ErrorDetail +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.core.decorators import service_availability +from utils.exceptions import ServiceUnavailable + + +# Create your views here. +@method_decorator(login_required, name='dispatch') +class HomeView(TemplateView): + template_name = 'core/home.html' + + +class StatusView(APIView): + permission_classes = [AllowAny] + + @service_availability('status') + def get(self, request, format=None): + + data = { + "status": "ok", + } + return Response(data) + + +class HealthcheckView(APIView): + permission_classes = [AllowAny] + def get(self, request, format=None): + data = { + "is_healthy": True + } + return Response(data) diff --git a/apps/gooyal_oauth2/__init__.py b/apps/gooyal_oauth2/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_oauth2/admin.py b/apps/gooyal_oauth2/admin.py new file mode 100755 index 0000000..40d618b --- /dev/null +++ b/apps/gooyal_oauth2/admin.py @@ -0,0 +1,13 @@ +""" +Django admin configuration for the gooyal-restrict-scopes package. +""" + +from django.contrib import admin +from django.contrib.admin.sites import NotRegistered + +from oauth2_provider.admin import ApplicationAdmin + + + +# The restricted application is registered by Django OAuth Toolkit, but we want +# to provide our own admin that uses our form diff --git a/apps/gooyal_oauth2/apps.py b/apps/gooyal_oauth2/apps.py new file mode 100755 index 0000000..e0ce9c1 --- /dev/null +++ b/apps/gooyal_oauth2/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GooyalOauthConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.gooyal_oauth2' diff --git a/apps/gooyal_oauth2/forms.py b/apps/gooyal_oauth2/forms.py new file mode 100644 index 0000000..b4b54bd --- /dev/null +++ b/apps/gooyal_oauth2/forms.py @@ -0,0 +1,9 @@ +""" +Django forms for use with the gooyal-restrict-scopes package. +""" + +from django import forms + +from oauth2_provider.scopes import get_scopes_backend + + diff --git a/apps/gooyal_oauth2/migrations/0001_initial.py b/apps/gooyal_oauth2/migrations/0001_initial.py new file mode 100644 index 0000000..eca0132 --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 5.1.4 on 2024-12-21 10:52 + +import oauth2_provider.generators +import oauth2_provider.models +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AccessToken', + fields=[ + ('token', models.TextField()), + ('token_checksum', oauth2_provider.models.TokenChecksumField(db_index=True, max_length=64, unique=True)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('detail', models.JSONField(blank=True, null=True)), + ('client_id', models.CharField(blank=True, max_length=255, null=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Application', + fields=[ + ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), + ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), + ('post_logout_redirect_uris', models.TextField(blank=True, default='', help_text='Allowed Post Logout URIs list, space separated')), + ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), + ('client_secret', oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255)), + ('hash_client_secret', models.BooleanField(default=True)), + ('name', models.CharField(blank=True, max_length=255)), + ('skip_authorization', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('algorithm', models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5)), + ('allowed_origins', models.TextField(blank=True, default='', help_text='Allowed origins list to enable CORS, space separated')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Grant', + fields=[ + ('code', models.CharField(max_length=255, unique=True)), + ('expires', models.DateTimeField()), + ('redirect_uri', models.TextField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('code_challenge', models.CharField(blank=True, default='', max_length=128)), + ('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)), + ('nonce', models.CharField(blank=True, default='', max_length=255)), + ('claims', models.TextField(blank=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='IDToken', + fields=[ + ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RefreshToken', + fields=[ + ('token', models.CharField(max_length=255)), + ('token_family', models.UUIDField(blank=True, editable=False, null=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('revoked', models.DateTimeField(null=True)), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/apps/gooyal_oauth2/migrations/0002_initial.py b/apps/gooyal_oauth2/migrations/0002_initial.py new file mode 100644 index 0000000..bce2e0e --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0002_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 5.1.4 on 2024-12-21 10:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('gooyal_oauth2', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='accesstoken', + name='client_owner', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='accesstoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='application', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='accesstoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='grant', + name='application', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='grant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='idtoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='idtoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ), + migrations.AddField( + model_name='refreshtoken', + name='access_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), + ), + migrations.AddField( + model_name='refreshtoken', + name='application', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='refreshtoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='accesstoken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ), + ] diff --git a/apps/gooyal_oauth2/migrations/0003_alter_application_authorization_grant_type.py b/apps/gooyal_oauth2/migrations/0003_alter_application_authorization_grant_type.py new file mode 100644 index 0000000..7f8ef42 --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0003_alter_application_authorization_grant_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.2 on 2026-02-25 13:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gooyal_oauth2', '0002_initial'), + ] + + operations = [ + 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), + ), + ] diff --git a/apps/gooyal_oauth2/migrations/__init__.py b/apps/gooyal_oauth2/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gooyal_oauth2/models.py b/apps/gooyal_oauth2/models.py new file mode 100644 index 0000000..abfe694 --- /dev/null +++ b/apps/gooyal_oauth2/models.py @@ -0,0 +1,45 @@ +# models +import uuid +from django.db import models +from oauth2_provider.models import AbstractApplication, AbstractAccessToken, AbstractGrant, AbstractRefreshToken, \ + AbstractIDToken + + +class AccessToken(AbstractAccessToken): + id=None + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) + detail = models.JSONField(null=True, blank=True) + client_id = models.CharField(max_length=255, null=True, blank=True) + client_owner = models.ForeignKey('users.User', on_delete=models.PROTECT, null=True, blank=True) + + class Meta: + abstract = False + + +class Application(AbstractApplication): + id=None + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) + class Meta: + abstract = False + + +class Grant(AbstractGrant): + id = None + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) + class Meta: + abstract = False + + +class RefreshToken(AbstractRefreshToken): + id = None + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) + class Meta: + abstract = False + + +class IDToken(AbstractIDToken): + id = None + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) + class Meta: + abstract = False + diff --git a/apps/gooyal_oauth2/rest_framework.py b/apps/gooyal_oauth2/rest_framework.py new file mode 100644 index 0000000..b9fec63 --- /dev/null +++ b/apps/gooyal_oauth2/rest_framework.py @@ -0,0 +1,19 @@ +import logging + +from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements, OAuth2Authentication +from rest_framework.permissions import ( + IsAuthenticated +) + +log = logging.getLogger("oauth2_provider") + + +class IsAuthenticatedOrTokenMatchesOASRequirements(TokenMatchesOASRequirements): + def has_permission(self, request, view): + is_authenticated = IsAuthenticated().has_permission(request, view) + oauth2authenticated = False + if is_authenticated: + oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication) + + token_has_scope = TokenMatchesOASRequirements() + return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view) diff --git a/apps/gooyal_oauth2/utils.py b/apps/gooyal_oauth2/utils.py new file mode 100644 index 0000000..269496f --- /dev/null +++ b/apps/gooyal_oauth2/utils.py @@ -0,0 +1,6 @@ +def get_application(request): + try: + application = request.auth.application + except: + application = None + return application diff --git a/apps/gooyal_oauth2/validators.py b/apps/gooyal_oauth2/validators.py new file mode 100755 index 0000000..e8de68e --- /dev/null +++ b/apps/gooyal_oauth2/validators.py @@ -0,0 +1,170 @@ +# import service_clients +import base64 +import http.client +import logging +from datetime import datetime, timedelta + +import requests +from django.conf import settings +from django.contrib.auth import get_user_model +from django.utils.timezone import make_aware +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_grant_model, + get_id_token_model, + get_refresh_token_model, +) +from oauth2_provider.oauth2_validators import OAuth2Validator as BaseOAuth2Validator +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.utils import get_timezone + +Application = get_application_model() +AccessToken = get_access_token_model() +IDToken = get_id_token_model() +Grant = get_grant_model() +RefreshToken = get_refresh_token_model() +UserModel = get_user_model() + + + +log = logging.getLogger("oauth2_provider") + + +class OAuth2Validator(BaseOAuth2Validator): # pylint: disable=w0223 + def get_or_create_user_from_content(self, content): + """ + An optional layer to define where to store the profile in `UserModel` or a separate model. + For example `UserOAuth`, where `user = models.OneToOneField(UserModel)` . + + The function is called after checking that username is in the content. + + Returns an UserModel instance; + """ + user, _ = UserModel.objects.get_or_create(pk=content["username"]) + return user + + def _get_token_from_authentication_server( + self, token, introspection_url, introspection_token, introspection_credentials + ): + # NOTICE: onlu change from orginal method is that we create application here + """Use external introspection endpoint to "crack open" the token. + :param introspection_url: introspection endpoint URL + :param introspection_token: Bearer token + :param introspection_credentials: Basic Auth credentials (id,secret) + :return: :class:`models.AccessToken` + + Some RFC 7662 implementations (including this one) use a Bearer token while others use Basic + Auth. Depending on the external AS's implementation, provide either the introspection_token + or the introspection_credentials. + + If the resulting access_token identifies a username (e.g. Authorization Code grant), add + that user to the UserModel. Also cache the access_token up until its expiry time or a + configured maximum time. + + """ + headers = None + if introspection_token: + headers = {"Authorization": "Bearer {}".format(introspection_token)} + elif introspection_credentials: + client_id = introspection_credentials[0].encode("utf-8") + client_secret = introspection_credentials[1].encode("utf-8") + basic_auth = base64.b64encode(client_id + b":" + client_secret) + headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))} + + try: + response = requests.post(introspection_url, data={"token": token}, headers=headers) + except requests.exceptions.RequestException: + log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) + return None + + # Log an exception when response from auth server is not successful + if response.status_code != http.client.OK: + log.exception( + "Introspection: Failed to get a valid response " + "from authentication server. Status code: {}, " + "Reason: {}.".format(response.status_code, response.reason) + ) + return None + + try: + content = response.json() + except ValueError: + log.exception("Introspection: Failed to parse response as json") + return None + + if "active" in content and content["active"] is True: + try: + application_introspection_url = introspection_url.rstrip("/") + "_application/" + response = requests.post(application_introspection_url, data={"client_id": content['client_id']}, headers=headers) + except requests.exceptions.RequestException: + log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) + return None + + if response.status_code != http.client.OK: + log.exception( + "Application introspection: Failed to get a valid response " + "from authentication server. Status code: {}, " + "Reason: {}.".format(response.status_code, response.reason) + ) + return None + + try: + application_introspection_content = response.json() + except ValueError: + log.exception("Introspection: Failed to parse response as json") + return None + + owner_value = None + application_uuid = None + if "active" in application_introspection_content and application_introspection_content["active"] is True: + if "owner" in application_introspection_content: + owner_value = application_introspection_content["owner"] + application_uuid = application_introspection_content.get("uuid") + + if owner_value: + owner, _ = UserModel.objects.get_or_create(pk=owner_value) + else: + owner = None + + if "username" in content: + user = self.get_or_create_user_from_content(content) + else: + user = None + + max_caching_time = datetime.now() + timedelta( + seconds=oauth2_settings.RESOURCE_SERVER_TOKEN_CACHING_SECONDS + ) + + if "exp" in content: + expires = datetime.utcfromtimestamp(content["exp"]) + if expires > max_caching_time: + expires = max_caching_time + else: + expires = max_caching_time + + scope = content.get("scope", "") + + if settings.USE_TZ: + expires = make_aware( + expires, timezone=get_timezone(oauth2_settings.AUTHENTICATION_SERVER_EXP_TIME_ZONE) + ) + + # TODO: get application owner and put it here + application, _created = Application.objects.get_or_create( + client_id=content["client_id"], + uuid=application_introspection_content["uuid"] + ) + access_token, _created = AccessToken.objects.update_or_create( + token=token, + defaults={ + "user": user, + "client_id": content["client_id"], + "client_owner": owner, + "application_id": application_uuid, + "scope": scope, + "expires": expires, + }, + ) + + return access_token \ No newline at end of file diff --git a/apps/users/__init__.py b/apps/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..41a4e09 --- /dev/null +++ b/apps/users/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from .models import User + + +admin.site.register(User) diff --git a/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..2bb189c --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.users' diff --git a/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..f8c51bd --- /dev/null +++ b/apps/users/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.4 on 2024-12-21 10:52 + +import apps.users.models +import django.contrib.auth.validators +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('username', models.CharField(blank=True, error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, null=True, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('last_update', models.DateTimeField(auto_now=True, max_length=30, null=True, verbose_name='last update')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', apps.users.models.UserManager()), + ], + ), + ] diff --git a/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..2b3ac1b --- /dev/null +++ b/apps/users/models.py @@ -0,0 +1,71 @@ +import requests +from django.conf import settings +from django.contrib.auth.base_user import BaseUserManager +from django.contrib.auth.models import AbstractUser +from django.contrib.auth.validators import UnicodeUsernameValidator +from django.db import models +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +import uuid +import random +import string +import base64 +import hashlib + + + +class UserManager(BaseUserManager): + use_in_migrations = True + + def _create_user(self, pk=None, **extra_fields): + user = self.model(pk=pk, **extra_fields) + user.date_joined = timezone.now() + user.save(using=self._db) + return user + + def create_user(self, pk, **extra_fields): + extra_fields.setdefault('is_staff', False) + extra_fields.setdefault('is_superuser', False) + + return self._create_user(pk=pk, **extra_fields) + + def create_superuser(self, username, email, password, **extra_fields): + if not username: + raise ValueError('The given username must be set') + + extra_fields.setdefault('is_staff', True) + extra_fields.setdefault('is_superuser', True) + + if extra_fields.get('is_staff') is not True: + raise ValueError('Superuser must have is_staff=True.') + if extra_fields.get('is_superuser') is not True: + raise ValueError('Superuser must have is_superuser=True.') + + return self._create_user(None, username=username, email=email, password=password, **extra_fields) + + +class User(AbstractUser): + uuid = models.UUIDField(primary_key=True, editable=False, default=uuid.uuid4, unique=True, db_index=True) + password = models.CharField(_('password'), max_length=128) + username_validator = UnicodeUsernameValidator() + username = models.CharField( + _('username'), + max_length=150, + unique=True, + help_text=_('Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.'), + validators=[username_validator], + error_messages={ + 'unique': _("A user with that username already exists."), + }, + blank=True, + null=True + ) + + last_update = models.DateTimeField(_('last update'), max_length=30, blank=True, null=True, auto_now=True) + last_login = models.DateTimeField(_('last login'), blank=True, null=True) + date_joined = models.DateTimeField(_('date joined'), default=timezone.now) + + objects = UserManager() + + def __str__(self): + return str(self.username or self.pk) diff --git a/apps/users/tests.py b/apps/users/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/users/views.py b/apps/users/views.py new file mode 100644 index 0000000..e30045b --- /dev/null +++ b/apps/users/views.py @@ -0,0 +1,3 @@ +from django.conf import settings + + diff --git a/main/__init__.py b/main/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/main/asgi.py b/main/asgi.py new file mode 100644 index 0000000..04b4e6d --- /dev/null +++ b/main/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for main 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() 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 new file mode 100644 index 0000000..c164732 --- /dev/null +++ b/main/settings.py @@ -0,0 +1,239 @@ +""" +Django settings for chat project. + +Generated by 'django-admin startproject' using Django 6.0.5. + +For more information on this file, see +https://docs.djangoproject.com/en/6.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/6.0/ref/settings/ +""" + +from pathlib import Path +from django.utils.translation import gettext_lazy as _ +from decouple import config + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-fmk!je04bz^-o#tuevr4wafj&1jna6*8*rh8f8po^is3k-0%oe' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = ['*'] + +# Application definition + +INSTALLED_APPS = [ + # django apps + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + + # pip installed apps + 'drf_spectacular', + 'oauth2_provider', + 'rest_framework', + 'corsheaders', + 'crispy_forms', + 'crispy_bootstrap5', + 'django_filters', + 'jalali_date', + 'django_admin_logs', + + # local apps + 'apps.core', + 'apps.users', + 'apps.gooyal_oauth2', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'oauth2_provider.middleware.OAuth2TokenMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'utils.exceptions.ErrorMiddleware', +] + +OAUTH2_PROVIDER_APPLICATION_MODEL = "gooyal_oauth2.Application" +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "gooyal_oauth2.AccessToken" +OAUTH2_PROVIDER_ID_TOKEN_MODEL = "gooyal_oauth2.IDToken" +OAUTH2_PROVIDER_GRANT_MODEL = "gooyal_oauth2.Grant" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "gooyal_oauth2.RefreshToken" + +OAUTH2_PROVIDER_BASE_PUBLIC_URL = config("OAUTH2_PROVIDER_BASE_PUBLIC_URL") +OAUTH2_PROVIDER_BASE_PRIVATE_URL = config("OAUTH2_PROVIDER_BASE_PRIVATE_URL") + +OAUTH2_PROVIDER_CLIENT_ID = config('OAUTH2_PROVIDER_CLIENT_ID') +OAUTH2_PROVIDER_CLIENT_SECRET = config('OAUTH2_PROVIDER_CLIENT_SECRET') +OAUTH2_PROVIDER_SCOPES = config('OAUTH2_PROVIDER_SCOPES', default='') +OAUTH2_PROVIDER = { + # this is the list of available scopes + # 'SCOPES_BACKEND_CLASS': 'apps.gooyal_oauth2.scopes.Scopes', + # 'SCOPES': {'read': 'Read scope', + # 'write': 'Write scope', + # 'groups': 'Access to your groups', + # 'introspection': 'Introspect token scope'}, + 'OAUTH2_VALIDATOR_CLASS': 'apps.gooyal_oauth2.validators.OAuth2Validator', + 'RESOURCE_SERVER_INTROSPECTION_URL': OAUTH2_PROVIDER_BASE_PRIVATE_URL + '/introspect/', + # 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', # OR this but not both: + 'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': (OAUTH2_PROVIDER_CLIENT_ID, OAUTH2_PROVIDER_CLIENT_SECRET), +} + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.TokenAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 200, + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + 'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', +} + +ROOT_URLCONF = 'main.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +AUTHENTICATION_BACKENDS = ( + 'oauth2_provider.backends.OAuth2Backend', + 'django.contrib.auth.backends.ModelBackend', +) +WSGI_APPLICATION = 'main.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + 'default': { + "ENGINE": "django.db.backends.postgresql", + 'NAME': config('DB_NAME'), + 'USER': config('DB_USER'), + 'PASSWORD': config('DB_PASSWORD'), + 'HOST': config('DB_HOST', '127.0.0.1'), + 'PORT': config('DB_PORT', ''), + } +} + +# Password validation +# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +# Internationalization +# https://docs.djangoproject.com/en/6.0/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'Asia/Tehran' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +LOCALE_PATHS = [ + BASE_DIR / 'locale', +] + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/6.0/howto/static-files/ + +STATIC_URL = 'static/' + +if DEBUG == True: + STATICFILES_DIRS = ( + BASE_DIR / 'static', + ) +else: + STATIC_ROOT = BASE_DIR / 'static' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + + +SPECTACULAR_SETTINGS = { + 'TITLE': 'IPG', + 'DESCRIPTION': 'service api reference', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + # OTHER SETTINGS + "PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"], + +} + +AUTH_USER_MODEL = 'users.User' +CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_ALL_ORIGINS = True +CSRF_TRUSTED_ORIGINS = ['http://*', 'https://*', + 'http://*.gooyal.com', 'https://*.gooyal.com', + 'http://*.addwin.ir', 'https://*.addwin.ir', + 'http://*.gooyal.ir', 'https://*.gooyal.ir', + ] + +JALALI_DATE_DEFAULTS = { + 'Strftime': { + 'date': '%Y/%m/%d', + 'datetime': '%H:%M:%S _ %Y/%m/%d', + } +} + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": config('REDIS_BASE_URL'), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} + +from main.other_settings.logging import get_logging_setting +LOGGING = get_logging_setting() \ No newline at end of file diff --git a/main/urls.py b/main/urls.py new file mode 100644 index 0000000..292d5e0 --- /dev/null +++ b/main/urls.py @@ -0,0 +1,43 @@ +""" +URL configuration for main project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.1/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.conf import settings +# from django.conf.urls import url +from django.conf.urls.static import static +from django.urls import path, include +from django.contrib import admin +from rest_framework import permissions +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + + +# from apps.gooyal_oauth2.views.introspect import introspect_token + +from django.contrib.auth import urls as auth_urls + + +urlpatterns = [ + path('swagger/', SpectacularAPIView.as_view(), name='schema'), + path('swagger/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('swagger/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), + + path('admin/', admin.site.urls), + path('', include('apps.pg.urls')), + # path('users/', include('apps.users.urls')), + path('oauth2/', include('oauth2_provider.urls', namespace='oauth2_provider')), +] + +urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/main/wsgi.py b/main/wsgi.py new file mode 100644 index 0000000..9b53308 --- /dev/null +++ b/main/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for main project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') + +application = get_wsgi_application() diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..fbda2b3 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..b8d94c6 --- /dev/null +++ b/requirements.in @@ -0,0 +1,22 @@ +Django +python-decouple +psycopg[binary,pool] +django-jalali-date +django-crispy-forms +crispy_bootstrap5 +djangorestframework +django-filter +django-cors-headers +gunicorn +gevent +drf-spectacular +pillow +requests +django-oauth-toolkit +redis +celery +httpx +django-redis +xlsxwriter +xlrd +django_admin_logs diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fb68c3b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,184 @@ +# +# This file is autogenerated by pip-compile with Python 3.13 +# by the following command: +# +# pip-compile +# +amqp==5.3.1 + # via kombu +anyio==4.13.0 + # via httpx +asgiref==3.11.1 + # via + # django + # django-cors-headers +attrs==26.1.0 + # via + # jsonschema + # referencing +billiard==4.2.4 + # via celery +celery==5.6.3 + # via -r requirements.in +certifi==2026.4.22 + # via + # httpcore + # httpx + # requests +cffi==2.0.0 + # via cryptography +charset-normalizer==3.4.7 + # via requests +click==8.3.3 + # via + # celery + # click-didyoumean + # click-plugins + # click-repl +click-didyoumean==0.3.1 + # via celery +click-plugins==1.1.1.2 + # via celery +click-repl==0.3.0 + # via celery +crispy-bootstrap5==2026.3 + # via -r requirements.in +cryptography==48.0.0 + # via jwcrypto +django==6.0.5 + # via + # -r requirements.in + # crispy-bootstrap5 + # django-admin-logs + # django-cors-headers + # django-crispy-forms + # django-filter + # django-jalali-date + # django-oauth-toolkit + # django-redis + # djangorestframework + # drf-spectacular +django-admin-logs==1.5.0 + # via -r requirements.in +django-cors-headers==4.9.0 + # via -r requirements.in +django-crispy-forms==2.6 + # via + # -r requirements.in + # crispy-bootstrap5 +django-filter==25.2 + # via -r requirements.in +django-jalali-date==2.0.0 + # via -r requirements.in +django-oauth-toolkit==3.2.0 + # via -r requirements.in +django-redis==6.0.0 + # via -r requirements.in +djangorestframework==3.17.1 + # via + # -r requirements.in + # drf-spectacular +drf-spectacular==0.29.0 + # via -r requirements.in +gevent==26.4.0 + # via -r requirements.in +greenlet==3.5.0 + # via gevent +gunicorn==26.0.0 + # via -r requirements.in +h11==0.16.0 + # via httpcore +httpcore==1.0.9 + # via httpx +httpx==0.28.1 + # via -r requirements.in +idna==3.13 + # via + # anyio + # httpx + # requests +inflection==0.5.1 + # via drf-spectacular +jalali-core==1.0.0 + # via jdatetime +jdatetime==5.2.0 + # via django-jalali-date +jsonschema==4.26.0 + # via drf-spectacular +jsonschema-specifications==2025.9.1 + # via jsonschema +jwcrypto==1.5.7 + # via django-oauth-toolkit +kombu==5.6.2 + # via celery +oauthlib==3.3.1 + # via django-oauth-toolkit +packaging==26.2 + # via + # gunicorn + # kombu +pillow==12.2.0 + # via -r requirements.in +prompt-toolkit==3.0.52 + # via click-repl +psycopg[binary,pool]==3.3.4 + # via -r requirements.in +psycopg-binary==3.3.4 + # via psycopg +psycopg-pool==3.3.1 + # via psycopg +pycparser==3.0 + # via cffi +python-dateutil==2.9.0.post0 + # via celery +python-decouple==3.8 + # via -r requirements.in +pyyaml==6.0.3 + # via drf-spectacular +redis==7.4.0 + # via + # -r requirements.in + # django-redis +referencing==0.37.0 + # via + # jsonschema + # jsonschema-specifications +requests==2.33.1 + # via + # -r requirements.in + # django-oauth-toolkit +rpds-py==0.30.0 + # via + # jsonschema + # referencing +six==1.17.0 + # via python-dateutil +sqlparse==0.5.5 + # via django +typing-extensions==4.15.0 + # via + # jwcrypto + # psycopg-pool +tzdata==2026.2 + # via kombu +tzlocal==5.3.1 + # via celery +uritemplate==4.2.0 + # via drf-spectacular +urllib3==2.7.0 + # via requests +vine==5.1.0 + # via + # amqp + # celery + # kombu +wcwidth==0.7.0 + # via prompt-toolkit +xlrd==2.0.2 + # via -r requirements.in +xlsxwriter==3.2.9 + # via -r requirements.in +zope-event==6.2 + # via gevent +zope-interface==8.4 + # via gevent diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..2ebdb8a --- /dev/null +++ b/run.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +while ! nc -z $DB_HOST 5432 ; do + echo "APP Waiting for the DB Server" + sleep 3 +done +#python3 manage.py collectstatic --noinput +python3 manage.py migrate +gunicorn main.wsgi:application --bind 0.0.0.0:8000 -w 4 \ No newline at end of file diff --git a/utils/exceptions.py b/utils/exceptions.py new file mode 100644 index 0000000..8e730cd --- /dev/null +++ b/utils/exceptions.py @@ -0,0 +1,100 @@ +import logging + +from django.conf import settings +from django.utils import timezone +from rest_framework.exceptions import APIException +from rest_framework.views import exception_handler as drf_exception_handler + +logger = logging.getLogger(__name__) + + +def exception_handler(exc, context): + logger.exception(exc) + # پاسخ پیش‌فرض DRF را دریافت می‌کنیم + response = drf_exception_handler(exc, context) + + if response is not None: + response_data = { + 'success': False, + 'status_code': response.status_code, + 'status_message': str(getattr(exc, 'default_detail', exc)), + 'details': getattr(exc, 'detail', None), + } + + if isinstance(exc, APIException): + try: + error = getattr(getattr(exc, 'detail', None), 'code', None) or getattr(exc, 'code', None) + if not error: + error = getattr(exc, 'default_code') + except: + error = '' + + # if isinstance(exc.detail, str): + # message = exc.detail + # elif isinstance(exc.detail, dict): + # if 'message' in exc.detail or 'string' in exc.detail: + # message = exc.detail.get('message') or exc.detail.get('string') + # else: + # message = error + # + # else: + # message = error + # + # response_data['message'] = message + response_data['details'] = {"message": exc.detail} + response_data['details']['error'] = error + response_data['details']['timestamp'] = timezone.now().isoformat() + + response.data = response_data + + return response + + +# در فایل middleware.py +from django.http import JsonResponse + + +class ErrorMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + return response + + def process_exception(self, request, exception): + logger.exception(exception) + return JsonResponse( + { + "success": False, + "status_code": 500, + "status_message": "Internal server error", + "details": str(exception) if settings.DEBUG else None, + }, + status=500 + ) + + +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + + +class UnprocessableEntity(APIException): + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + default_detail = 'The request was well-formed but cannot be processed due to semantic errors.' + default_code = 'unprocessable_entity' + + +class ServiceUnavailable(APIException): + status_code = status.HTTP_503_SERVICE_UNAVAILABLE + default_ = _('SERVICE_UNAVAILABLE') + default_code = 'service_unavailable' + default_detail = 'Service Unavailable' + + +class Conflict(APIException): + status_code = status.HTTP_409_CONFLICT + default_ = _('CONFLICT') + default_code = 'conflict' + default_detail = 'Conflict'