From 3d836e8d79e5b63a9a13b16a9fcb75b1e02a7383 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi Date: Tue, 22 Jul 2025 16:05:03 +0330 Subject: [PATCH] init --- .gitignore | 5 + Dockerfile | 12 ++ README.srt | 2 + 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 +++++++++ apps/gooyal_oauth2/migrations/__init__.py | 0 apps/gooyal_oauth2/models.py | 45 +++++ apps/gooyal_oauth2/rest_framework.py | 27 +++ 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/settings.py | 123 +++++++++++++ main/urls.py | 22 +++ main/wsgi.py | 16 ++ manage.py | 22 +++ requirements.in | 19 ++ run.sh | 13 ++ scripts/app.cron | 1 + utils/__init__.py | 0 utils/logs/__init__.py | 4 + utils/logs/formatters.py | 77 ++++++++ utils/logs/handlers.py | 58 ++++++ 35 files changed, 986 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.srt 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/__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/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 100755 run.sh create mode 100644 scripts/app.cron create mode 100644 utils/__init__.py create mode 100644 utils/logs/__init__.py create mode 100644 utils/logs/formatters.py create mode 100644 utils/logs/handlers.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36cad18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +.env +venv +media +/clients/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4f14e9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM debian:12 +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 +COPY requirements.txt /app/requirements.txt +RUN pip3 install --break-system-packages -r requirements.txt +RUN pip3 install --break-system-packages httpx +RUN apt install cron +#ENTRYPOINT ["./run.sh"] diff --git a/README.srt b/README.srt new file mode 100644 index 0000000..30ed91d --- /dev/null +++ b/README.srt @@ -0,0 +1,2 @@ + +openapi-python-client for api generation \ No newline at end of file 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/__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..3a08f0e --- /dev/null +++ b/apps/gooyal_oauth2/rest_framework.py @@ -0,0 +1,27 @@ +import logging + +from oauth2_provider.contrib.rest_framework import TokenMatchesOASRequirements, OAuth2Authentication +from rest_framework.permissions import ( + IsAuthenticated +) + +logger = logging.getLogger("oauth2_provider") + + +class IsAuthenticatedOrTokenMatchesOASRequirements(TokenMatchesOASRequirements): + def has_permission(self, request, view): + logger.debug(f'try to authenticate {request} for {view} in IsAuthenticatedOrTokenMatchesOASRequirements') + is_authenticated = IsAuthenticated().has_permission(request, view) + logger.debug(f'is_authenticated: {is_authenticated}') + oauth2authenticated = False + if is_authenticated: + oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication) + + logger.debug(f'oauth2authenticated: {oauth2authenticated}') + + token_has_scope = TokenMatchesOASRequirements() + logger.debug(f'token_has_scope: {token_has_scope}') + + result = (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view) + logger.debug(f'authentication result: {result}') + return result 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/settings.py b/main/settings.py new file mode 100644 index 0000000..838b000 --- /dev/null +++ b/main/settings.py @@ -0,0 +1,123 @@ +""" +Django settings for main project. + +Generated by 'django-admin startproject' using Django 5.1.4. + +For more information on this file, see +https://docs.djangoproject.com/en/5.1/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.1/ref/settings/ +""" + +from pathlib import Path + +# 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/5.1/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-ync*tk)t*bz3zzbza&xvw6cl+wk6+@gk^@)2)-pp=^&v%@knib' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'main.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + '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', + ], + }, + }, +] + +WSGI_APPLICATION = 'main.wsgi.application' + + +# Database +# https://docs.djangoproject.com/en/5.1/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': BASE_DIR / 'db.sqlite3', + } +} + + +# Password validation +# https://docs.djangoproject.com/en/5.1/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/5.1/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.1/howto/static-files/ + +STATIC_URL = 'static/' + +# Default primary key field type +# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' diff --git a/main/urls.py b/main/urls.py new file mode 100644 index 0000000..84efccc --- /dev/null +++ b/main/urls.py @@ -0,0 +1,22 @@ +""" +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.contrib import admin +from django.urls import path + +urlpatterns = [ + path('admin/', admin.site.urls), +] 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..295666a --- /dev/null +++ b/requirements.in @@ -0,0 +1,19 @@ +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 +django-redis +django_minio_backend \ No newline at end of file diff --git a/run.sh b/run.sh new file mode 100755 index 0000000..6cb70be --- /dev/null +++ b/run.sh @@ -0,0 +1,13 @@ +#!/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 + +# env > .env +# service cron start +# crontab app.cron + +gunicorn main.wsgi:application --bind 0.0.0.0:8000 -w 4 \ No newline at end of file diff --git a/scripts/app.cron b/scripts/app.cron new file mode 100644 index 0000000..3567860 --- /dev/null +++ b/scripts/app.cron @@ -0,0 +1 @@ +* * * * * cd /app && /usr/bin/python3 manage.py refund_ad_balance diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/utils/logs/__init__.py b/utils/logs/__init__.py new file mode 100644 index 0000000..5afab06 --- /dev/null +++ b/utils/logs/__init__.py @@ -0,0 +1,4 @@ +from .formatters import LokiFormatter +from .handlers import LokiHandler + +__all__ = ['LokiHandler', 'LokiFormatter'] \ No newline at end of file diff --git a/utils/logs/formatters.py b/utils/logs/formatters.py new file mode 100644 index 0000000..def86d5 --- /dev/null +++ b/utils/logs/formatters.py @@ -0,0 +1,77 @@ +import logging +import socket +from datetime import datetime +from logging import BASIC_FORMAT + + +class LokiFormatter(logging.Formatter): + asctime_search = "%(asctime)" + tz = "UTC" + source = "Loki" + src_host = "localhost" + tags = {} + + def __init__(self, fmt, dfmt, style, fqdn=False): + super(LokiFormatter, self).__init__() + + self.fmt = fmt or BASIC_FORMAT + self.dfmt = dfmt or "%Y-%m-%d %H:%M:%S" + self.style = style + + if fqdn: + self.host = socket.getfqdn() + else: + self.host = socket.gethostname() + + def format_timestamp(self, time): + return str(int(time * 10**9)) + + def usesTime(self): + return self.fmt.find(self.asctime_search) >= 0 + + def formatMessage(self, record): + try: + return self.fmt % record.__dict__ + except KeyError as e: + raise ValueError("Formatting field not found in record: %s" % e) + + def format(self, record): + record.message = record.getMessage() + + if self.usesTime(): + record.asctime = self.formatTime(record, self.dfmt) + + message = self.formatMessage(record) + + if record.exc_info: + if not record.exc_text: + record.exc_text = self.formatException(record.exc_info) + + if record.exc_text: + if message[-1:] != "\n": + message = message + "\n" + message = message + record.exc_text + + if record.stack_info: + if message[-1:] != "\n": + message = message + "\n" + + message = message + self.formatStack(record.stack_info) + + message = { + "streams": [ + { + "stream": { + **self.tags, + }, + "values": [ + [ + self.format_timestamp(record.created), + message, + ] + ], + } + ] + } + + return message diff --git a/utils/logs/handlers.py b/utils/logs/handlers.py new file mode 100644 index 0000000..a647255 --- /dev/null +++ b/utils/logs/handlers.py @@ -0,0 +1,58 @@ +import logging +import sys +from threading import Thread + +import requests + + +class LokiHandler(logging.Handler): + def __init__( + self, + timeout=0.5, + url="http://localhost:3100/loki/api/v1/push", + auth=None, + tags={}, + mode="sync", + ): + super(LokiHandler, self).__init__() + + self._url = url + self._timeout = timeout + self._auth = auth + self._tags = tags + + self._mode = mode + + def emit(self, record): + try: + payload = self.formatter.format(record) + + _push_message( + self._url, json=payload, timeout=self._timeout, auth=self._auth + ) + except requests.exceptions.ReadTimeout: + sys.stderr.write("Loki connection timed out\n") + except Exception as e: + sys.stderr.write(f"Loki connection failed with: {e}\n") + + def setFormatter(self, fmt): + fmt.tags = self._tags + + self.formatter = fmt + + def _push_message(self, *args, **kwargs): + print('hehe') + if self._mode == "sync": + return _push_message(*args, **kwargs) + + if self._mode == "thread": + return Thread(target=_push_message, args=args, kwargs=kwargs).start() + + +def _push_message(*args, **kwargs): + response = requests.post(*args, **kwargs) + + if response.status_code != 204: + sys.stderr.write( + f"Got status {response.status_code} from loki with message: {response.text}\n" + )