From 9d39c866335b16484f84b06304388e279e015aa7 Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Wed, 18 Sep 2024 17:12:11 +0700 Subject: [PATCH 01/54] feat: [#8641] create organization oauth2 --- common/apps/jwks/urls.py | 3 ++- common/apps/oauth2/views.py | 5 +++-- common/apps/organization/models.py | 3 ++- common/apps/organization/tasks.py | 5 +++-- common/apps/organization_role/admin.py | 3 ++- .../migrations/0002_create_default_policies.py | 3 +-- common/apps/organization_role/models.py | 5 +++-- common/apps/organization_user/admin.py | 3 ++- common/apps/organization_user/models.py | 5 +++-- common/apps/refresh_tokens/models.py | 3 ++- common/apps/refresh_tokens/serializers.py | 15 ++++++++------- common/apps/refresh_tokens/services.py | 3 ++- common/apps/space/admin.py | 3 ++- common/apps/space/models.py | 3 ++- common/apps/space_role/admin.py | 3 ++- .../migrations/0002_create_default_policies.py | 3 +-- common/apps/space_role/models.py | 5 +++-- common/celery/task_handler.py | 13 +++++++++++++ common/celery/task_senders.py | 5 +++-- common/celery/types.py | 7 +++++++ common/models/synchronous_model.py | 5 +++-- common/permissions/permission_classes.py | 5 +++-- common/utils/oauth2.py | 3 ++- common/utils/social_provider.py | 1 + common/views/space.py | 5 +++-- setup.cfg | 3 +-- 26 files changed, 79 insertions(+), 41 deletions(-) create mode 100644 common/celery/task_handler.py create mode 100644 common/celery/types.py diff --git a/common/apps/jwks/urls.py b/common/apps/jwks/urls.py index ec11fbf..4d7f421 100644 --- a/common/apps/jwks/urls.py +++ b/common/apps/jwks/urls.py @@ -1,6 +1,7 @@ -from common.apps.jwks.views import JWKView from django.urls import path +from common.apps.jwks.views import JWKView + app_name = "jwks" urlpatterns = [ diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index a5ec57f..43e5e20 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -1,13 +1,14 @@ import logging from operator import itemgetter -from common.apps.oauth2.serializers import OauthLoginSerializer -from common.utils.oauth2 import get_access_token, handle_access_token from rest_framework import generics, status from rest_framework.exceptions import ParseError from rest_framework.permissions import AllowAny from rest_framework.response import Response +from common.apps.oauth2.serializers import OauthLoginSerializer +from common.utils.oauth2 import get_access_token, handle_access_token + class GoogleLoginView(generics.CreateAPIView): serializer_class = OauthLoginSerializer diff --git a/common/apps/organization/models.py b/common/apps/organization/models.py index b64adce..9754364 100644 --- a/common/apps/organization/models.py +++ b/common/apps/organization/models.py @@ -1,7 +1,8 @@ -from common.models.base_model import BaseModel from django.db import models from django_tenants.models import DomainMixin, TenantMixin +from common.models.base_model import BaseModel + class Organization(TenantMixin, BaseModel): name = models.CharField(max_length=100) diff --git a/common/apps/organization/tasks.py b/common/apps/organization/tasks.py index c338704..6a9304c 100644 --- a/common/apps/organization/tasks.py +++ b/common/apps/organization/tasks.py @@ -1,11 +1,12 @@ import logging -from common.apps.organization.models import Domain, Organization -from common.celery.tasks import task from django.conf import settings from django.db import transaction from django.utils.module_loading import import_string +from common.apps.organization.models import Domain, Organization +from common.celery.tasks import task + logger = logging.getLogger(__name__) diff --git a/common/apps/organization_role/admin.py b/common/apps/organization_role/admin.py index b1ce3c4..1a6ffd8 100644 --- a/common/apps/organization_role/admin.py +++ b/common/apps/organization_role/admin.py @@ -1,9 +1,10 @@ +from django.contrib import admin + from common.apps.organization_role.models import ( OrganizationPolicy, OrganizationRole, OrganizationRoleUser, ) -from django.contrib import admin @admin.register(OrganizationPolicy) diff --git a/common/apps/organization_role/migrations/0002_create_default_policies.py b/common/apps/organization_role/migrations/0002_create_default_policies.py index 9aa0ff8..850ba55 100644 --- a/common/apps/organization_role/migrations/0002_create_default_policies.py +++ b/common/apps/organization_role/migrations/0002_create_default_policies.py @@ -1,8 +1,7 @@ # Generated by Django 5.0.6 on 2024-06-21 07:20 -from django.db import migrations - from common.apps.organization_role.constants import OrganizationPermission +from django.db import migrations default_policies = [ { diff --git a/common/apps/organization_role/models.py b/common/apps/organization_role/models.py index 37586eb..00d5944 100644 --- a/common/apps/organization_role/models.py +++ b/common/apps/organization_role/models.py @@ -1,9 +1,10 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models + from common.apps.organization_role.constants import OrganizationPermission from common.apps.organization_user.models import OrganizationUser from common.models.base_model import BaseModel from common.models.synchronous_model import SynchronousTenantModel -from django.contrib.postgres.fields import ArrayField -from django.db import models class OrganizationPolicy(BaseModel, SynchronousTenantModel): diff --git a/common/apps/organization_user/admin.py b/common/apps/organization_user/admin.py index bdf7b57..913a7cc 100644 --- a/common/apps/organization_user/admin.py +++ b/common/apps/organization_user/admin.py @@ -1,10 +1,11 @@ """Integrate with admin module.""" -from common.apps.organization_user.models import OrganizationUser from django.contrib import admin from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin from django.utils.translation import gettext_lazy as _ +from common.apps.organization_user.models import OrganizationUser + @admin.register(OrganizationUser) class UserAdmin(DjangoUserAdmin): diff --git a/common/apps/organization_user/models.py b/common/apps/organization_user/models.py index f938d72..39cae5c 100644 --- a/common/apps/organization_user/models.py +++ b/common/apps/organization_user/models.py @@ -1,11 +1,12 @@ -from common.models.synchronous_model import SynchronousTenantModel -from common.utils.social_provider import SocialProvider from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils.translation import gettext_lazy as _ +from common.models.synchronous_model import SynchronousTenantModel +from common.utils.social_provider import SocialProvider + class UserManager(BaseUserManager): """Define a model manager for User model with no username field.""" diff --git a/common/apps/refresh_tokens/models.py b/common/apps/refresh_tokens/models.py index a4ac94d..4f024e7 100644 --- a/common/apps/refresh_tokens/models.py +++ b/common/apps/refresh_tokens/models.py @@ -1,8 +1,9 @@ -from common.models.base_model import BaseModel from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ +from common.models.base_model import BaseModel + class RefreshTokenFamilyStatus(models.TextChoices): Active = "Active", _("Active") diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index de432f5..3daa666 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -1,12 +1,5 @@ import logging -from common.apps.refresh_tokens.models import ( - RefreshToken, - RefreshTokenFamilyStatus, - RefreshTokenStatus, -) -from common.apps.refresh_tokens.services import create_refresh_token -from common.utils.social_provider import SocialProvider from django.conf import settings from django.contrib.auth import authenticate, get_user_model from django.utils.module_loading import import_string @@ -19,6 +12,14 @@ ) from rest_framework_simplejwt.settings import api_settings +from common.apps.refresh_tokens.models import ( + RefreshToken, + RefreshTokenFamilyStatus, + RefreshTokenStatus, +) +from common.apps.refresh_tokens.services import create_refresh_token +from common.utils.social_provider import SocialProvider + JWTRefreshToken = import_string(settings.REFRESH_TOKEN_CLASS) User = get_user_model() diff --git a/common/apps/refresh_tokens/services.py b/common/apps/refresh_tokens/services.py index 1418b55..03b2f67 100644 --- a/common/apps/refresh_tokens/services.py +++ b/common/apps/refresh_tokens/services.py @@ -1,8 +1,9 @@ -from common.apps.refresh_tokens.models import RefreshToken, RefreshTokenFamily from django.conf import settings from django.utils.module_loading import import_string from rest_framework_simplejwt.settings import api_settings +from common.apps.refresh_tokens.models import RefreshToken, RefreshTokenFamily + JWTRefreshToken = import_string(settings.REFRESH_TOKEN_CLASS) diff --git a/common/apps/space/admin.py b/common/apps/space/admin.py index dfc3282..eb684f4 100644 --- a/common/apps/space/admin.py +++ b/common/apps/space/admin.py @@ -1,6 +1,7 @@ -from common.apps.space.models import Space from django.contrib import admin +from common.apps.space.models import Space + @admin.register(Space) class SpaceAdmin(admin.ModelAdmin): diff --git a/common/apps/space/models.py b/common/apps/space/models.py index ab5be49..39c7ca6 100644 --- a/common/apps/space/models.py +++ b/common/apps/space/models.py @@ -1,7 +1,8 @@ +from django.db import models + from common.apps.organization_user.models import OrganizationUser from common.models.base_model import BaseModel from common.models.synchronous_model import SynchronousTenantModel -from django.db import models class Space(BaseModel, SynchronousTenantModel): diff --git a/common/apps/space_role/admin.py b/common/apps/space_role/admin.py index 6213bd7..5c3db13 100644 --- a/common/apps/space_role/admin.py +++ b/common/apps/space_role/admin.py @@ -1,6 +1,7 @@ -from common.apps.space_role.models import SpacePolicy, SpaceRole, SpaceRoleUser from django.contrib import admin +from common.apps.space_role.models import SpacePolicy, SpaceRole, SpaceRoleUser + @admin.register(SpacePolicy) class SpacePolicyAdmin(admin.ModelAdmin): diff --git a/common/apps/space_role/migrations/0002_create_default_policies.py b/common/apps/space_role/migrations/0002_create_default_policies.py index 007d61b..e9bed6c 100644 --- a/common/apps/space_role/migrations/0002_create_default_policies.py +++ b/common/apps/space_role/migrations/0002_create_default_policies.py @@ -1,8 +1,7 @@ # Generated by Django 5.0.6 on 2024-06-21 07:20 -from django.db import migrations - from common.apps.space_role.constants import SpacePermission +from django.db import migrations default_policies = [ { diff --git a/common/apps/space_role/models.py b/common/apps/space_role/models.py index 35194f1..8d4206e 100644 --- a/common/apps/space_role/models.py +++ b/common/apps/space_role/models.py @@ -1,10 +1,11 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models + from common.apps.organization_user.models import OrganizationUser from common.apps.space.models import Space from common.apps.space_role.constants import SpacePermission from common.models.base_model import BaseModel from common.models.synchronous_model import SynchronousTenantModel -from django.contrib.postgres.fields import ArrayField -from django.db import models class SpacePolicy(BaseModel, SynchronousTenantModel): diff --git a/common/celery/task_handler.py b/common/celery/task_handler.py new file mode 100644 index 0000000..7113a6d --- /dev/null +++ b/common/celery/task_handler.py @@ -0,0 +1,13 @@ +from abc import ABC, abstractmethod + + +class TaskHandlerBase(ABC): + @abstractmethod + def handle(self, *args, **kwargs): + raise NotImplementedError("Method must be implemented") + + +class TaskProcessorBase: + @abstractmethod + def process(self, *args, **kwargs): + raise NotImplementedError("Method must be implemented") diff --git a/common/celery/task_senders.py b/common/celery/task_senders.py index 22477a0..ec2538e 100644 --- a/common/celery/task_senders.py +++ b/common/celery/task_senders.py @@ -2,9 +2,9 @@ from django.utils.module_loading import import_string -def send_task(name, message): +def send_task(name, message, **kwargs): celery_app = import_string(settings.CELERY_APP) - celery_app.send_task( + return celery_app.send_task( name=f"spacedf.tasks.{name}", exchange=name, routing_key=f"spacedf.tasks.{name}", @@ -13,4 +13,5 @@ def send_task(name, message): max_retries=3, interval_start=3, interval_step=1, interval_max=6 ), kwargs=message, + **kwargs, ) diff --git a/common/celery/types.py b/common/celery/types.py new file mode 100644 index 0000000..5e2a8c4 --- /dev/null +++ b/common/celery/types.py @@ -0,0 +1,7 @@ +AUTH_SERVICE_PROCESSING_QUEUE = "auth_service_processing_queue" +AUTH_SERVICE_PROCESSING_TASK = "auth_service_processing" +AUTH_SERVICE_OAUTH_CREDENTIALS_TYPE = "auth_service_oauth_credentials" + +CONSOLE_SERVICE_PROCESSING_QUEUE = "console_service_processing_queue" +CONSOLE_SERVICE_PROCESSING_TASK = "console_service_processing" +CONSOLE_SERVICE_AUTHORIZATED_USER = "console_service_authorizated_user" diff --git a/common/models/synchronous_model.py b/common/models/synchronous_model.py index a6d2f9b..9802cb9 100644 --- a/common/models/synchronous_model.py +++ b/common/models/synchronous_model.py @@ -1,8 +1,9 @@ -from common.celery.task_senders import send_task -from common.utils.model_to_dict import model_to_dict from django.conf import settings from django.db import connection, models +from common.celery.task_senders import send_task +from common.utils.model_to_dict import model_to_dict + class SynchronousTenantModel(models.Model): """ diff --git a/common/permissions/permission_classes.py b/common/permissions/permission_classes.py index d002fbc..0425445 100644 --- a/common/permissions/permission_classes.py +++ b/common/permissions/permission_classes.py @@ -1,8 +1,9 @@ -from common.apps.organization_role.models import OrganizationPolicy -from common.apps.space_role.models import SpacePolicy from rest_framework.exceptions import ParseError from rest_framework.permissions import BasePermission +from common.apps.organization_role.models import OrganizationPolicy +from common.apps.space_role.models import SpacePolicy + def is_method(methods): class IsMethodRequest(BasePermission): diff --git a/common/utils/oauth2.py b/common/utils/oauth2.py index 98fa013..63cdce8 100644 --- a/common/utils/oauth2.py +++ b/common/utils/oauth2.py @@ -3,12 +3,13 @@ from typing import Literal import requests -from common.apps.refresh_tokens.services import create_refresh_token from django.conf import settings from django.contrib.auth import get_user_model from rest_framework import status from rest_framework.response import Response +from common.apps.refresh_tokens.services import create_refresh_token + User = get_user_model() diff --git a/common/utils/social_provider.py b/common/utils/social_provider.py index 9e29d60..3c9b92d 100644 --- a/common/utils/social_provider.py +++ b/common/utils/social_provider.py @@ -4,3 +4,4 @@ class SocialProvider(models.TextChoices): GOOGLE = "google" NONE_PROVIDER = None + SPACE_DF = "space_df" diff --git a/common/views/space.py b/common/views/space.py index e27c528..2886b0b 100644 --- a/common/views/space.py +++ b/common/views/space.py @@ -1,10 +1,11 @@ -from common.apps.space.models import Space -from common.swagger.params import get_space_header_params from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins from rest_framework.exceptions import ParseError from rest_framework.generics import GenericAPIView +from common.apps.space.models import Space +from common.swagger.params import get_space_header_params + class SpaceAPIView(GenericAPIView): space_field = None diff --git a/setup.cfg b/setup.cfg index 01e8981..4bdba11 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,5 +16,4 @@ use_parentheses = true [coverage:run] include = console-service/* omit = *migrations*, *tests* -plugins = - django_coverage_plugin +plugins = django_coverage_plugin From 99de84646d0bbb83df3d07a2c53c8afe877bc73c Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Wed, 2 Oct 2024 14:31:39 +0700 Subject: [PATCH 02/54] bug: #9945 sensitive mail --- common/apps/refresh_tokens/serializers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index 3daa666..aa8fc51 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -31,7 +31,8 @@ def authenticate(self, email: str, password: str): self.user = None try: self.user = User.objects.get( - email=email, providers__contains=[SocialProvider.NONE_PROVIDER] + email__icontains=email, + providers__contains=[SocialProvider.NONE_PROVIDER], ) except User.DoesNotExist as e: logging.exception(e) From ec92449bef96fca0d2409d62633785dd25144154 Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Wed, 9 Oct 2024 10:07:02 +0700 Subject: [PATCH 03/54] fix: #8849 able to use jwt on another organization --- common/apps/refresh_tokens/jwts.py | 30 ++++++++++++++++++++--- common/apps/refresh_tokens/serializers.py | 10 +++++--- common/apps/refresh_tokens/services.py | 7 ++++-- common/utils/subdomain.py | 18 ++++++++++++++ 4 files changed, 56 insertions(+), 9 deletions(-) create mode 100644 common/utils/subdomain.py diff --git a/common/apps/refresh_tokens/jwts.py b/common/apps/refresh_tokens/jwts.py index 6b1e77a..87c99ab 100644 --- a/common/apps/refresh_tokens/jwts.py +++ b/common/apps/refresh_tokens/jwts.py @@ -1,8 +1,12 @@ import jwt +from django.db import connection +from rest_framework.exceptions import AuthenticationFailed from rest_framework_simplejwt.backends import TokenBackend from rest_framework_simplejwt.settings import api_settings from rest_framework_simplejwt.tokens import AccessToken, RefreshToken +from common.utils.subdomain import extract_subdomain + class CustomTokenBackend(TokenBackend): def encode(self, payload): @@ -14,7 +18,6 @@ def encode(self, payload): jwt_payload["aud"] = self.audience if self.issuer is not None: jwt_payload["iss"] = self.issuer - token = jwt.encode( jwt_payload, self.signing_key, @@ -25,6 +28,9 @@ def encode(self, payload): return token + def set_iss(self, issuer: str): + self.issuer = issuer + token_backend = CustomTokenBackend( api_settings.ALGORITHM, @@ -38,7 +44,21 @@ def encode(self, payload): ) -class CustomAccessToken(AccessToken): +class TokenVerifier: + def verify(self) -> None: + self.check_iss() + return super().verify() + + def check_iss(self): + issuer = self.payload.get("iss", None) + if not issuer: + raise AuthenticationFailed("Token is not valid") + subdomain = extract_subdomain(issuer) + if not subdomain or subdomain != connection.tenant.slug_name: + raise AuthenticationFailed("Token is not valid") + + +class CustomAccessToken(TokenVerifier, AccessToken): @property def token_backend(self): if self._token_backend is None: @@ -46,7 +66,7 @@ def token_backend(self): return self._token_backend -class CustomRefreshToken(RefreshToken): +class CustomRefreshToken(TokenVerifier, RefreshToken): access_token_class = CustomAccessToken @property @@ -54,3 +74,7 @@ def token_backend(self): if self._token_backend is None: self._token_backend = token_backend return self._token_backend + + def set_iss(self, claim: str = "iss", issuer=None) -> None: + self.token_backend.set_iss(issuer=issuer) + self.payload[claim] = issuer diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index aa8fc51..b0907c5 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -31,7 +31,7 @@ def authenticate(self, email: str, password: str): self.user = None try: self.user = User.objects.get( - email__icontains=email, + email__iexact=email, providers__contains=[SocialProvider.NONE_PROVIDER], ) except User.DoesNotExist as e: @@ -51,9 +51,10 @@ def validate(self, attrs): self.error_messages["no_active_account"], "no_active_account", ) - - refresh_token, access_token = create_refresh_token(self.user) - + tenant = None + if hasattr(self.context["request"], "tenant"): + tenant = self.context["request"].tenant + refresh_token, access_token = create_refresh_token(self.user, issuer=tenant) data["refresh"] = str(refresh_token) data["access"] = str(access_token) @@ -66,6 +67,7 @@ class CustomTokenRefreshSerializer(TokenRefreshSerializer): def validate(self, attrs): refresh = self.token_class(attrs["refresh"]) + refresh.check_iss() refresh_token_obj = ( RefreshToken.objects.filter(jti=refresh.payload[api_settings.JTI_CLAIM]) .select_related("family") diff --git a/common/apps/refresh_tokens/services.py b/common/apps/refresh_tokens/services.py index 03b2f67..6c39719 100644 --- a/common/apps/refresh_tokens/services.py +++ b/common/apps/refresh_tokens/services.py @@ -3,13 +3,16 @@ from rest_framework_simplejwt.settings import api_settings from common.apps.refresh_tokens.models import RefreshToken, RefreshTokenFamily +from common.utils.subdomain import update_subdomain JWTRefreshToken = import_string(settings.REFRESH_TOKEN_CLASS) -def create_refresh_token(user): +def create_refresh_token(user, issuer=None, **kwargs): refresh = JWTRefreshToken.for_user(user) - + if issuer: + domain = update_subdomain(settings.HOST, issuer.slug_name) + refresh.set_iss("iss", domain) token_family = RefreshTokenFamily(user=user) token_family.save() RefreshToken( diff --git a/common/utils/subdomain.py b/common/utils/subdomain.py new file mode 100644 index 0000000..4d51dd3 --- /dev/null +++ b/common/utils/subdomain.py @@ -0,0 +1,18 @@ +from urllib.parse import urlparse, urlunparse + + +def update_subdomain(url, subdomain): + parsed_url = urlparse(url) + domain = parsed_url.netloc + new_netloc = f"{subdomain}.{domain}" + new_url = urlunparse(parsed_url._replace(netloc=new_netloc)) + return new_url + + +def extract_subdomain(url): + parsed_url = urlparse(url) + domain_with_subdomain = parsed_url.hostname + parts = domain_with_subdomain.split(".") + if len(parts) > 1: + return parts[0] + return None From 90dab1d47ad0ef8ba6a74ef730766e635fe3afe8 Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Tue, 22 Oct 2024 11:21:57 +0700 Subject: [PATCH 04/54] feat: credentials api --- common/permissions/permission_classes.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/common/permissions/permission_classes.py b/common/permissions/permission_classes.py index 0425445..9f54041 100644 --- a/common/permissions/permission_classes.py +++ b/common/permissions/permission_classes.py @@ -1,3 +1,4 @@ +from django.conf import settings from rest_framework.exceptions import ParseError from rest_framework.permissions import BasePermission @@ -62,3 +63,17 @@ def has_permission(self, request, view): ] return HasPermissionAccess + + +class HasAPIKey(BasePermission): + def has_permission(self, request, view): + spacedf_key = request.headers.get("x-api-key", None) + # TODO: need model for this + return settings.ROOT_API_KEY == spacedf_key + + +class HasRootAPIKey(BasePermission): + def has_permission(self, request, view): + spacedf_key = request.headers.get("x-root-api-key", None) + + return settings.ROOT_API_KEY == spacedf_key From 870444f31e754b4fa79a5a85b60ab36df55c1c6d Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Tue, 22 Oct 2024 11:21:57 +0700 Subject: [PATCH 05/54] feat: credentials api --- common/permissions/permission_classes.py | 15 +++++++++++++++ common/swagger/params.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/common/permissions/permission_classes.py b/common/permissions/permission_classes.py index 0425445..9f54041 100644 --- a/common/permissions/permission_classes.py +++ b/common/permissions/permission_classes.py @@ -1,3 +1,4 @@ +from django.conf import settings from rest_framework.exceptions import ParseError from rest_framework.permissions import BasePermission @@ -62,3 +63,17 @@ def has_permission(self, request, view): ] return HasPermissionAccess + + +class HasAPIKey(BasePermission): + def has_permission(self, request, view): + spacedf_key = request.headers.get("x-api-key", None) + # TODO: need model for this + return settings.ROOT_API_KEY == spacedf_key + + +class HasRootAPIKey(BasePermission): + def has_permission(self, request, view): + spacedf_key = request.headers.get("x-root-api-key", None) + + return settings.ROOT_API_KEY == spacedf_key diff --git a/common/swagger/params.py b/common/swagger/params.py index 9fb79f1..ca91376 100644 --- a/common/swagger/params.py +++ b/common/swagger/params.py @@ -1,4 +1,13 @@ from drf_yasg import openapi +from drf_yasg.inspectors import SwaggerAutoSchema + +api_key_param = openapi.Parameter( + name="x-api-key", + in_=openapi.IN_HEADER, + type=openapi.TYPE_STRING, + required=True, + description="API Key for authentication", +) def get_space_header_params(required=True): @@ -12,3 +21,10 @@ def get_space_header_params(required=True): default="", ) ] + + +class CustomSwaggerAutoSchema(SwaggerAutoSchema): + def get_operation(self, operation_keys=None): + operation = super().get_operation(operation_keys) + operation["parameters"] = operation.get("parameters", []) + [api_key_param] + return operation From 7f9476fcac3469f8a9a748af07880ba90c2463a5 Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Tue, 29 Oct 2024 09:42:35 +0700 Subject: [PATCH 06/54] chore: move organization role management --- common/apps/organization_role/models.py | 6 ++-- common/apps/refresh_tokens/serializers.py | 3 +- common/apps/space/models.py | 7 ++-- common/apps/space_role/models.py | 6 ++-- common/permissions/permission_classes.py | 39 +++++++---------------- common/swagger/params.py | 1 + 6 files changed, 27 insertions(+), 35 deletions(-) diff --git a/common/apps/organization_role/models.py b/common/apps/organization_role/models.py index 00d5944..71f8a80 100644 --- a/common/apps/organization_role/models.py +++ b/common/apps/organization_role/models.py @@ -1,11 +1,13 @@ +from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.db import models from common.apps.organization_role.constants import OrganizationPermission -from common.apps.organization_user.models import OrganizationUser from common.models.base_model import BaseModel from common.models.synchronous_model import SynchronousTenantModel +User = get_user_model() + class OrganizationPolicy(BaseModel, SynchronousTenantModel): name = models.CharField(max_length=256) @@ -28,7 +30,7 @@ class OrganizationRoleUser(BaseModel, SynchronousTenantModel): on_delete=models.CASCADE, ) organization_user = models.ForeignKey( - OrganizationUser, + User, related_name="organization_role_user", on_delete=models.CASCADE, ) diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index b0907c5..38e8ca8 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -67,7 +67,8 @@ class CustomTokenRefreshSerializer(TokenRefreshSerializer): def validate(self, attrs): refresh = self.token_class(attrs["refresh"]) - refresh.check_iss() + if hasattr(self.context["request"], "tenant"): + refresh.check_iss() refresh_token_obj = ( RefreshToken.objects.filter(jti=refresh.payload[api_settings.JTI_CLAIM]) .select_related("family") diff --git a/common/apps/space/models.py b/common/apps/space/models.py index 39c7ca6..ea13790 100644 --- a/common/apps/space/models.py +++ b/common/apps/space/models.py @@ -1,9 +1,12 @@ +from django.contrib.auth import get_user_model from django.db import models -from common.apps.organization_user.models import OrganizationUser +# from common.apps.organization_user.models import OrganizationUser from common.models.base_model import BaseModel from common.models.synchronous_model import SynchronousTenantModel +User = get_user_model() + class Space(BaseModel, SynchronousTenantModel): name = models.CharField(max_length=256) @@ -11,7 +14,7 @@ class Space(BaseModel, SynchronousTenantModel): slug_name = models.SlugField(max_length=64, unique=True) is_active = models.BooleanField(default=True) created_by = models.ForeignKey( - OrganizationUser, + User, related_name="created_space", on_delete=models.SET_NULL, default=None, diff --git a/common/apps/space_role/models.py b/common/apps/space_role/models.py index 8d4206e..00f50f1 100644 --- a/common/apps/space_role/models.py +++ b/common/apps/space_role/models.py @@ -1,12 +1,14 @@ +from django.contrib.auth import get_user_model from django.contrib.postgres.fields import ArrayField from django.db import models -from common.apps.organization_user.models import OrganizationUser from common.apps.space.models import Space from common.apps.space_role.constants import SpacePermission from common.models.base_model import BaseModel from common.models.synchronous_model import SynchronousTenantModel +User = get_user_model() + class SpacePolicy(BaseModel, SynchronousTenantModel): name = models.CharField(max_length=256) @@ -32,5 +34,5 @@ class SpaceRoleUser(BaseModel, SynchronousTenantModel): on_delete=models.CASCADE, ) organization_user = models.ForeignKey( - OrganizationUser, related_name="space_role_user", on_delete=models.CASCADE + User, related_name="space_role_user", on_delete=models.CASCADE ) diff --git a/common/permissions/permission_classes.py b/common/permissions/permission_classes.py index 9f54041..49a03aa 100644 --- a/common/permissions/permission_classes.py +++ b/common/permissions/permission_classes.py @@ -1,10 +1,12 @@ from django.conf import settings +from django.contrib.auth import get_user_model from rest_framework.exceptions import ParseError from rest_framework.permissions import BasePermission -from common.apps.organization_role.models import OrganizationPolicy from common.apps.space_role.models import SpacePolicy +User = get_user_model() + def is_method(methods): class IsMethodRequest(BasePermission): @@ -43,26 +45,14 @@ def has_permission(self, request, view): return HasPermissionAccess - -def has_organization_permission_access(permission): - """ - Allows access only to users who have specific organization permissions. - """ - - class HasPermissionAccess(BasePermission): - __permission = permission - - def has_permission(self, request, view): - policies = OrganizationPolicy.objects.filter( - organizationrole__organization_role_user__organization_user_id=request.user.id, - ).distinct() - return self.__permission in [ - policy_permission - for policy in policies - for policy_permission in policy.permissions - ] - - return HasPermissionAccess +class IsOwner(BasePermission): + def has_object_permission(self, request, view, obj): + try: + user = User.objects.get(id=request.user.id) + return user.organization_users.is_owner + except User.DoesNotExist: + return False + return super().has_object_permission(request, view, obj) class HasAPIKey(BasePermission): @@ -70,10 +60,3 @@ def has_permission(self, request, view): spacedf_key = request.headers.get("x-api-key", None) # TODO: need model for this return settings.ROOT_API_KEY == spacedf_key - - -class HasRootAPIKey(BasePermission): - def has_permission(self, request, view): - spacedf_key = request.headers.get("x-root-api-key", None) - - return settings.ROOT_API_KEY == spacedf_key diff --git a/common/swagger/params.py b/common/swagger/params.py index ca91376..8a9651d 100644 --- a/common/swagger/params.py +++ b/common/swagger/params.py @@ -7,6 +7,7 @@ type=openapi.TYPE_STRING, required=True, description="API Key for authentication", + default="111-xxx-222", ) From df5fe07db083a6933fd0ebfc017aec8112778405 Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Tue, 29 Oct 2024 10:15:20 +0700 Subject: [PATCH 07/54] chore: move organization role management --- common/apps/organization_role/__init__.py | 0 common/apps/organization_role/admin.py | 41 ----- common/apps/organization_role/apps.py | 6 - common/apps/organization_role/constants.py | 19 --- .../migrations/0001_initial.py | 145 ------------------ .../0002_create_default_policies.py | 75 --------- .../organization_role/migrations/__init__.py | 0 common/apps/organization_role/models.py | 36 ----- common/apps/organization_role/tasks.py | 18 --- common/apps/refresh_tokens/serializers.py | 7 +- common/apps/space/models.py | 1 - common/permissions/permission_classes.py | 1 + 12 files changed, 7 insertions(+), 342 deletions(-) delete mode 100644 common/apps/organization_role/__init__.py delete mode 100644 common/apps/organization_role/admin.py delete mode 100644 common/apps/organization_role/apps.py delete mode 100644 common/apps/organization_role/constants.py delete mode 100644 common/apps/organization_role/migrations/0001_initial.py delete mode 100644 common/apps/organization_role/migrations/0002_create_default_policies.py delete mode 100644 common/apps/organization_role/migrations/__init__.py delete mode 100644 common/apps/organization_role/models.py delete mode 100644 common/apps/organization_role/tasks.py diff --git a/common/apps/organization_role/__init__.py b/common/apps/organization_role/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/common/apps/organization_role/admin.py b/common/apps/organization_role/admin.py deleted file mode 100644 index 1a6ffd8..0000000 --- a/common/apps/organization_role/admin.py +++ /dev/null @@ -1,41 +0,0 @@ -from django.contrib import admin - -from common.apps.organization_role.models import ( - OrganizationPolicy, - OrganizationRole, - OrganizationRoleUser, -) - - -@admin.register(OrganizationPolicy) -class OrganizationPolicyAdmin(admin.ModelAdmin): - list_display = ( - "id", - "name", - "description", - "tags", - "actions", - "created_at", - "updated_at", - ) - - -@admin.register(OrganizationRole) -class OrganizationRoleAdmin(admin.ModelAdmin): - list_display = ( - "id", - "name", - "created_at", - "updated_at", - ) - - -@admin.register(OrganizationRoleUser) -class OrganizationRoleUserAdmin(admin.ModelAdmin): - list_display = ( - "id", - "organization_role", - "organization_user", - "created_at", - "updated_at", - ) diff --git a/common/apps/organization_role/apps.py b/common/apps/organization_role/apps.py deleted file mode 100644 index ae11516..0000000 --- a/common/apps/organization_role/apps.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.apps import AppConfig - - -class OrganizationRoleConfig(AppConfig): - default_auto_field = "django.db.models.BigAutoField" - name = "common.apps.organization_role" diff --git a/common/apps/organization_role/constants.py b/common/apps/organization_role/constants.py deleted file mode 100644 index e221c34..0000000 --- a/common/apps/organization_role/constants.py +++ /dev/null @@ -1,19 +0,0 @@ -from django.db import models - - -class OrganizationPermission(models.TextChoices): - # Organization - UPDATE_ORGANIZATION = "UPDATE_ORGANIZATION" - DELETE_ORGANIZATION = "DELETE_ORGANIZATION" - - # Organization Role - READ_ORGANIZATION_ROLE = "READ_ORGANIZATION_ROLE" - CREATE_ORGANIZATION_ROLE = "CREATE_ORGANIZATION_ROLE" - UPDATE_ORGANIZATION_ROLE = "UPDATE_ORGANIZATION_ROLE" - DELETE_ORGANIZATION_ROLE = "DELETE_ORGANIZATION_ROLE" - - # Organization Member - READ_ORGANIZATION_MEMBER = "READ_ORGANIZATION_MEMBER" - INVITE_ORGANIZATION_MEMBER = "INVITE_ORGANIZATION_MEMBER" - UPDATE_ORGANIZATION_MEMBER_ROLE = "UPDATE_ORGANIZATION_MEMBER_ROLE" - REMOVE_ORGANIZATION_MEMBER = "REMOVE_ORGANIZATION_MEMBER" diff --git a/common/apps/organization_role/migrations/0001_initial.py b/common/apps/organization_role/migrations/0001_initial.py deleted file mode 100644 index 6e967b1..0000000 --- a/common/apps/organization_role/migrations/0001_initial.py +++ /dev/null @@ -1,145 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-26 10:09 - -import django.contrib.postgres.fields -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name="OrganizationPolicy", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=256)), - ("description", models.TextField()), - ( - "tags", - django.contrib.postgres.fields.ArrayField( - base_field=models.CharField(max_length=256), size=None - ), - ), - ( - "permissions", - django.contrib.postgres.fields.ArrayField( - base_field=models.CharField( - choices=[ - ("UPDATE_ORGANIZATION", "Update Organization"), - ("DELETE_ORGANIZATION", "Delete Organization"), - ("READ_ORGANIZATION_ROLE", "Read Organization Role"), - ( - "CREATE_ORGANIZATION_ROLE", - "Create Organization Role", - ), - ( - "UPDATE_ORGANIZATION_ROLE", - "Update Organization Role", - ), - ( - "DELETE_ORGANIZATION_ROLE", - "Delete Organization Role", - ), - ( - "READ_ORGANIZATION_MEMBER", - "Read Organization Member", - ), - ( - "INVITE_ORGANIZATION_MEMBER", - "Invite Organization Member", - ), - ( - "UPDATE_ORGANIZATION_MEMBER_ROLE", - "Update Organization Member Role", - ), - ( - "REMOVE_ORGANIZATION_MEMBER", - "Remove Organization Member", - ), - ], - max_length=256, - ), - size=None, - ), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.CreateModel( - name="OrganizationRole", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ("name", models.CharField(max_length=256)), - ( - "policies", - models.ManyToManyField(to="organization_role.organizationpolicy"), - ), - ], - options={ - "abstract": False, - }, - ), - migrations.CreateModel( - name="OrganizationRoleUser", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("created_at", models.DateTimeField(auto_now_add=True)), - ("updated_at", models.DateTimeField(auto_now=True)), - ( - "organization_role", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="organization_role_user", - to="organization_role.organizationrole", - ), - ), - ( - "organization_user", - models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, - related_name="organization_role_user", - to=settings.AUTH_USER_MODEL, - ), - ), - ], - options={ - "abstract": False, - }, - ), - ] diff --git a/common/apps/organization_role/migrations/0002_create_default_policies.py b/common/apps/organization_role/migrations/0002_create_default_policies.py deleted file mode 100644 index 850ba55..0000000 --- a/common/apps/organization_role/migrations/0002_create_default_policies.py +++ /dev/null @@ -1,75 +0,0 @@ -# Generated by Django 5.0.6 on 2024-06-21 07:20 - -from common.apps.organization_role.constants import OrganizationPermission -from django.db import migrations - -default_policies = [ - { - "name": "Administrator access", - "description": "Provides full access to services and resources", - "tags": ["administrator"], - "permissions": [permission.value for permission in OrganizationPermission], - }, - { - "name": "Organization full access", - "description": "Grants full access to Organization resources and access to related services", - "tags": ["organization", "full access"], - "permissions": [ - OrganizationPermission.UPDATE_ORGANIZATION, - OrganizationPermission.DELETE_ORGANIZATION, - ], - }, - { - "name": "Organization's Role read-only access", - "description": "Provide read only access to Organization's Role services", - "tags": ["organization-role", "read-only"], - "permissions": [ - OrganizationPermission.READ_ORGANIZATION_ROLE, - ], - }, - { - "name": "Organization's Role full access", - "description": "Grants full access to Organization's Role resources and access to related services", - "tags": ["organization-role", "full-access"], - "permissions": [ - OrganizationPermission.READ_ORGANIZATION_ROLE, - OrganizationPermission.CREATE_ORGANIZATION_ROLE, - OrganizationPermission.UPDATE_ORGANIZATION_ROLE, - OrganizationPermission.DELETE_ORGANIZATION_ROLE, - ], - }, - { - "name": "Organization's Member read-only access", - "description": "Provide read only access to Organization's Member services", - "tags": ["organization-member", "read-only"], - "permissions": [ - OrganizationPermission.READ_ORGANIZATION_MEMBER, - ], - }, - { - "name": "Organization's Member full access", - "description": "Grants full access to Organization's Member resources and access to related services", - "tags": ["organization-member", "full-access"], - "permissions": [ - OrganizationPermission.READ_ORGANIZATION_MEMBER, - OrganizationPermission.INVITE_ORGANIZATION_MEMBER, - OrganizationPermission.UPDATE_ORGANIZATION_MEMBER_ROLE, - OrganizationPermission.REMOVE_ORGANIZATION_MEMBER, - ], - }, -] - - -def create_default_policy(apps, schema_editor): - OrganizationPolicy = apps.get_model("organization_role", "OrganizationPolicy") - - for policy in default_policies: - OrganizationPolicy(**policy).save() - - -class Migration(migrations.Migration): - dependencies = [ - ("organization_role", "0001_initial"), - ] - - operations = [migrations.RunPython(create_default_policy)] diff --git a/common/apps/organization_role/migrations/__init__.py b/common/apps/organization_role/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/common/apps/organization_role/models.py b/common/apps/organization_role/models.py deleted file mode 100644 index 71f8a80..0000000 --- a/common/apps/organization_role/models.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.contrib.auth import get_user_model -from django.contrib.postgres.fields import ArrayField -from django.db import models - -from common.apps.organization_role.constants import OrganizationPermission -from common.models.base_model import BaseModel -from common.models.synchronous_model import SynchronousTenantModel - -User = get_user_model() - - -class OrganizationPolicy(BaseModel, SynchronousTenantModel): - name = models.CharField(max_length=256) - description = models.TextField() - tags = ArrayField(models.CharField(max_length=256)) - permissions = ArrayField( - models.CharField(max_length=256, choices=OrganizationPermission.choices) - ) - - -class OrganizationRole(BaseModel, SynchronousTenantModel): - name = models.CharField(max_length=256) - policies = models.ManyToManyField(OrganizationPolicy) - - -class OrganizationRoleUser(BaseModel, SynchronousTenantModel): - organization_role = models.ForeignKey( - OrganizationRole, - related_name="organization_role_user", - on_delete=models.CASCADE, - ) - organization_user = models.ForeignKey( - User, - related_name="organization_role_user", - on_delete=models.CASCADE, - ) diff --git a/common/apps/organization_role/tasks.py b/common/apps/organization_role/tasks.py deleted file mode 100644 index 4f4415f..0000000 --- a/common/apps/organization_role/tasks.py +++ /dev/null @@ -1,18 +0,0 @@ -from common.apps.organization_role.models import ( - OrganizationPolicy, - OrganizationRole, - OrganizationRoleUser, -) -from common.celery.tasks import create_tenant_model_shared_tasks - -( - update_organization_policy, - delete_organization_policy, -) = create_tenant_model_shared_tasks(OrganizationPolicy) -update_organization_role, delete_organization_role = create_tenant_model_shared_tasks( - OrganizationRole -) -( - update_organization_role_user, - delete_organization_role_user, -) = create_tenant_model_shared_tasks(OrganizationRoleUser) diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index 38e8ca8..ad3af0d 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -4,7 +4,7 @@ from django.contrib.auth import authenticate, get_user_model from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ -from rest_framework import exceptions +from rest_framework import exceptions, serializers from rest_framework_simplejwt.exceptions import TokenError from rest_framework_simplejwt.serializers import ( TokenObtainPairSerializer, @@ -104,3 +104,8 @@ def validate(self, attrs): data["refresh"] = str(refresh) return data + + +class TokenPairSerializer(serializers.Serializer): + access = serializers.CharField() + refresh = serializers.CharField() diff --git a/common/apps/space/models.py b/common/apps/space/models.py index ea13790..e9741d5 100644 --- a/common/apps/space/models.py +++ b/common/apps/space/models.py @@ -1,7 +1,6 @@ from django.contrib.auth import get_user_model from django.db import models -# from common.apps.organization_user.models import OrganizationUser from common.models.base_model import BaseModel from common.models.synchronous_model import SynchronousTenantModel diff --git a/common/permissions/permission_classes.py b/common/permissions/permission_classes.py index 49a03aa..dede8ac 100644 --- a/common/permissions/permission_classes.py +++ b/common/permissions/permission_classes.py @@ -45,6 +45,7 @@ def has_permission(self, request, view): return HasPermissionAccess + class IsOwner(BasePermission): def has_object_permission(self, request, view, obj): try: From f6e79930fdf7c82b41c4f51909ed01a6731636ac Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Wed, 30 Oct 2024 11:04:27 +0700 Subject: [PATCH 08/54] chore: update swagger schema --- common/swagger/params.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/common/swagger/params.py b/common/swagger/params.py index 8a9651d..252321c 100644 --- a/common/swagger/params.py +++ b/common/swagger/params.py @@ -1,15 +1,6 @@ from drf_yasg import openapi from drf_yasg.inspectors import SwaggerAutoSchema -api_key_param = openapi.Parameter( - name="x-api-key", - in_=openapi.IN_HEADER, - type=openapi.TYPE_STRING, - required=True, - description="API Key for authentication", - default="111-xxx-222", -) - def get_space_header_params(required=True): return [ @@ -27,5 +18,17 @@ def get_space_header_params(required=True): class CustomSwaggerAutoSchema(SwaggerAutoSchema): def get_operation(self, operation_keys=None): operation = super().get_operation(operation_keys) + api_key_param = openapi.Parameter( + name="x-api-key", + in_=openapi.IN_HEADER, + type=openapi.TYPE_STRING, + required=True, + description="API Key for authentication", + ) + if any( + path in self.path for path in ["api/health", "docs", "api/auth", "admin"] + ): + api_key_param["required"] = False + operation["parameters"] = operation.get("parameters", []) + [api_key_param] return operation From 6eebdce818360e9547af21dd6a728cc6e84d3f66 Mon Sep 17 00:00:00 2001 From: pikann Date: Fri, 1 Nov 2024 19:15:49 +0700 Subject: [PATCH 09/54] chore: update token obtain pair serializer name --- common/apps/refresh_tokens/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index ad3af0d..54b454d 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -24,7 +24,7 @@ User = get_user_model() -class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): +class BaseTokenObtainPairSerializer(TokenObtainPairSerializer): token_class = JWTRefreshToken def authenticate(self, email: str, password: str): From 436358d8124c0873189ef1724b5682c02c29f65b Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Wed, 6 Nov 2024 10:04:39 +0700 Subject: [PATCH 10/54] chore: update get devices api --- .../0003_alter_organizationuser_providers.py | 30 +++++++++++++++++++ .../migrations/0003_space_total_devices.py | 20 +++++++++++++ common/apps/space/models.py | 2 ++ common/celery/constants.py | 1 + common/permissions/permission_classes.py | 1 - setup.py | 5 +++- 6 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 common/apps/organization_user/migrations/0003_alter_organizationuser_providers.py create mode 100644 common/apps/space/migrations/0003_space_total_devices.py diff --git a/common/apps/organization_user/migrations/0003_alter_organizationuser_providers.py b/common/apps/organization_user/migrations/0003_alter_organizationuser_providers.py new file mode 100644 index 0000000..f248088 --- /dev/null +++ b/common/apps/organization_user/migrations/0003_alter_organizationuser_providers.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.6 on 2024-11-06 04:22 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("organization_user", "0002_organizationuser_providers"), + ] + + operations = [ + migrations.AlterField( + model_name="organizationuser", + name="providers", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("google", "Google"), + ("None", "None Provider"), + ("space_df", "Space DF"), + ], + max_length=256, + null=True, + ), + default=["None"], + size=None, + ), + ), + ] diff --git a/common/apps/space/migrations/0003_space_total_devices.py b/common/apps/space/migrations/0003_space_total_devices.py new file mode 100644 index 0000000..a82aa92 --- /dev/null +++ b/common/apps/space/migrations/0003_space_total_devices.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-11-06 02:00 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("space", "0002_remove_space_is_multi_tenant_space_created_by"), + ] + + operations = [ + migrations.AddField( + model_name="space", + name="total_devices", + field=models.IntegerField( + default=0, validators=[django.core.validators.MinValueValidator(0)] + ), + ), + ] diff --git a/common/apps/space/models.py b/common/apps/space/models.py index e9741d5..64710f6 100644 --- a/common/apps/space/models.py +++ b/common/apps/space/models.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from django.core.validators import MinValueValidator from django.db import models from common.models.base_model import BaseModel @@ -12,6 +13,7 @@ class Space(BaseModel, SynchronousTenantModel): logo = models.CharField(max_length=256) slug_name = models.SlugField(max_length=64, unique=True) is_active = models.BooleanField(default=True) + total_devices = models.IntegerField(default=0, validators=[MinValueValidator(0)]) created_by = models.ForeignKey( User, related_name="created_space", diff --git a/common/celery/constants.py b/common/celery/constants.py index 7623c62..34a2c5d 100644 --- a/common/celery/constants.py +++ b/common/celery/constants.py @@ -1,2 +1,3 @@ AUTH_SERVICE = "auth_service" AUTH_SERVICE_OAUTH_CREDENTIALS_CREATION = f"{AUTH_SERVICE}.oauth_credentials_creation" +AUTH_SERVICE_ADD_OR_REMOVE_DEVICE = f"{AUTH_SERVICE}.add_or_remove_device" diff --git a/common/permissions/permission_classes.py b/common/permissions/permission_classes.py index dede8ac..bef332d 100644 --- a/common/permissions/permission_classes.py +++ b/common/permissions/permission_classes.py @@ -53,7 +53,6 @@ def has_object_permission(self, request, view, obj): return user.organization_users.is_owner except User.DoesNotExist: return False - return super().has_object_permission(request, view, obj) class HasAPIKey(BasePermission): diff --git a/setup.py b/setup.py index 40d04db..7a6729c 100644 --- a/setup.py +++ b/setup.py @@ -2,4 +2,7 @@ from setuptools import find_packages -setup(name="django-common-utils", packages=find_packages()) +setup( + name="django-common-utils", + packages=find_packages(), +) From e51a0144a2f3b2d15bf444baf8011601354983c9 Mon Sep 17 00:00:00 2001 From: pikann Date: Wed, 13 Nov 2024 15:46:37 +0700 Subject: [PATCH 11/54] feat: add permission to access token --- common/apps/refresh_tokens/serializers.py | 37 ++++++++++++++++------- common/apps/refresh_tokens/services.py | 2 +- common/permissions/permission_classes.py | 7 ++++- common/utils/oauth2.py | 4 +-- 4 files changed, 35 insertions(+), 15 deletions(-) diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index 54b454d..f6d3356 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -17,7 +17,7 @@ RefreshTokenFamilyStatus, RefreshTokenStatus, ) -from common.apps.refresh_tokens.services import create_refresh_token +from common.apps.refresh_tokens.services import create_jwt_tokens from common.utils.social_provider import SocialProvider JWTRefreshToken = import_string(settings.REFRESH_TOKEN_CLASS) @@ -43,22 +43,28 @@ def authenticate(self, email: str, password: str): } self.user = authenticate(**authenticate_kwargs) + def get_tokens(self): + tenant = None + if hasattr(self.context["request"], "tenant"): + tenant = self.context["request"].tenant + refresh_token, access_token = create_jwt_tokens(self.user, issuer=tenant) + + return refresh_token, access_token + + def get_response_data(self): + refresh_token, access_token = self.get_tokens() + + return {"refresh": str(refresh_token), "access": str(access_token)} + def validate(self, attrs): - data = {} self.authenticate(email=attrs["email"], password=attrs["password"]) if not self.user: raise exceptions.AuthenticationFailed( self.error_messages["no_active_account"], "no_active_account", ) - tenant = None - if hasattr(self.context["request"], "tenant"): - tenant = self.context["request"].tenant - refresh_token, access_token = create_refresh_token(self.user, issuer=tenant) - data["refresh"] = str(refresh_token) - data["access"] = str(access_token) - return data + return self.get_response_data() class CustomTokenRefreshSerializer(TokenRefreshSerializer): @@ -66,9 +72,9 @@ class CustomTokenRefreshSerializer(TokenRefreshSerializer): def validate(self, attrs): refresh = self.token_class(attrs["refresh"]) - if hasattr(self.context["request"], "tenant"): refresh.check_iss() + refresh_token_obj = ( RefreshToken.objects.filter(jti=refresh.payload[api_settings.JTI_CLAIM]) .select_related("family") @@ -86,7 +92,16 @@ def validate(self, attrs): if refresh_token_obj.family.status != RefreshTokenFamilyStatus.Active: raise TokenError(_("Refresh token is inactive")) - data = {"access": str(refresh.access_token)} + if "access_token_handler" in self.context: + space_slug_name = self.context["request"].headers.get("X-Space") + access = self.context["access_token_handler"]( + space_slug=space_slug_name, + user_id=refresh.payload["user_id"], + access_token=refresh.access_token, + ) + data = {"access": str(access)} + else: + data = {"access": str(refresh.access_token)} refresh.set_jti() refresh.set_exp() diff --git a/common/apps/refresh_tokens/services.py b/common/apps/refresh_tokens/services.py index 6c39719..01946b9 100644 --- a/common/apps/refresh_tokens/services.py +++ b/common/apps/refresh_tokens/services.py @@ -8,7 +8,7 @@ JWTRefreshToken = import_string(settings.REFRESH_TOKEN_CLASS) -def create_refresh_token(user, issuer=None, **kwargs): +def create_jwt_tokens(user, issuer=None, **kwargs): refresh = JWTRefreshToken.for_user(user) if issuer: domain = update_subdomain(settings.HOST, issuer.slug_name) diff --git a/common/permissions/permission_classes.py b/common/permissions/permission_classes.py index dede8ac..d8e6bfb 100644 --- a/common/permissions/permission_classes.py +++ b/common/permissions/permission_classes.py @@ -53,7 +53,6 @@ def has_object_permission(self, request, view, obj): return user.organization_users.is_owner except User.DoesNotExist: return False - return super().has_object_permission(request, view, obj) class HasAPIKey(BasePermission): @@ -61,3 +60,9 @@ def has_permission(self, request, view): spacedf_key = request.headers.get("x-api-key", None) # TODO: need model for this return settings.ROOT_API_KEY == spacedf_key + + +class HasSpaceName(BasePermission): + def has_permission(self, request, view): + space_slug_name = request.headers.get("X-Space", None) + return bool(space_slug_name) diff --git a/common/utils/oauth2.py b/common/utils/oauth2.py index 63cdce8..08941cb 100644 --- a/common/utils/oauth2.py +++ b/common/utils/oauth2.py @@ -8,7 +8,7 @@ from rest_framework import status from rest_framework.response import Response -from common.apps.refresh_tokens.services import create_refresh_token +from common.apps.refresh_tokens.services import create_jwt_tokens User = get_user_model() @@ -62,7 +62,7 @@ def handle_access_token(access_token, provider: Literal["GOOGLE"]): root_user.providers.append(provider.lower()) root_user.save() - refresh, access = create_refresh_token(root_user) + refresh, access = create_jwt_tokens(root_user) return Response( status=status.HTTP_200_OK, data={"refresh": str(refresh), "access": str(access)} ) From a8d6c3337e9c082e6a4568879709fdbf720fb426 Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Fri, 15 Nov 2024 15:38:25 +0700 Subject: [PATCH 12/54] feat: #11188 add user's permission to access token --- common/apps/refresh_tokens/serializers.py | 12 ++--- common/authentication/user_authentication.py | 57 ++++++++++++++++++++ 2 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 common/authentication/user_authentication.py diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index f6d3356..f2c18f5 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -93,12 +93,12 @@ def validate(self, attrs): raise TokenError(_("Refresh token is inactive")) if "access_token_handler" in self.context: - space_slug_name = self.context["request"].headers.get("X-Space") - access = self.context["access_token_handler"]( - space_slug=space_slug_name, - user_id=refresh.payload["user_id"], - access_token=refresh.access_token, - ) + params = { + "access_token": refresh.access_token, + "user_id": refresh.payload["user_id"], + **self.context["access_token_handler_params"] + } + access = self.context["access_token_handler"](**params) data = {"access": str(access)} else: data = {"access": str(refresh.access_token)} diff --git a/common/authentication/user_authentication.py b/common/authentication/user_authentication.py new file mode 100644 index 0000000..c7b34b6 --- /dev/null +++ b/common/authentication/user_authentication.py @@ -0,0 +1,57 @@ +from typing import TypeVar + +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AbstractBaseUser +from django.utils.translation import gettext_lazy as _ +from rest_framework.authentication import BaseAuthentication +from rest_framework.request import Request +from rest_framework_simplejwt.exceptions import AuthenticationFailed +from rest_framework_simplejwt.models import TokenUser +from rest_framework_simplejwt.settings import api_settings + +AuthUser = TypeVar("AuthUser", AbstractBaseUser, TokenUser) + + +# TODO: replace JWTAuthentication by this on other service when ready +class UserAuthentication(BaseAuthentication): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.user_model = get_user_model() + + def authenticate(self, request: Request): + header = self.get_header(request) + if not header: + return None + + user = self.get_user(header) + + if not user: + return None + return (user, None) + + def get_header(self, request: Request) -> bytes: + """ + Extracts the header containing the `User Id` from the given + request. + """ + header = request.META.get("user-id") + + if isinstance(header, str): + # Work around django test client oddness + header = header.encode(header) + + return header + + def get_user(self, user_id: str) -> AuthUser: + """ + Attempts to find and return a user using the given `User Id`. + """ + try: + user = self.user_model.objects.get(**{api_settings.USER_ID_FIELD: user_id}) + except self.user_model.DoesNotExist: + raise AuthenticationFailed(_("User not found"), code="user_not_found") + + if not user.is_active: + raise AuthenticationFailed(_("User is inactive"), code="user_inactive") + + return user From 9bc8ab9386b4aaeaa9674a0b097386b5f4f3ec25 Mon Sep 17 00:00:00 2001 From: Nguyen Minh Ngoc Date: Wed, 20 Nov 2024 15:12:46 +0700 Subject: [PATCH 13/54] chore: #11286 change to uuid primary key --- .../organization/migrations/0001_initial.py | 39 ++++++++++--------- .../migrations/0001_initial.py | 39 ++++++++++++++----- .../0002_organizationuser_providers.py | 26 ------------- .../0003_alter_organizationuser_providers.py | 30 -------------- common/apps/organization_user/models.py | 5 +++ .../refresh_tokens/migrations/0001_initial.py | 17 ++++---- common/apps/space/migrations/0001_initial.py | 35 ++++++++++++++--- ..._space_is_multi_tenant_space_created_by.py | 30 -------------- .../migrations/0003_space_total_devices.py | 20 ---------- .../space_role/migrations/0001_initial.py | 24 +++++++----- .../0002_create_default_policies.py | 2 - common/models/base_model.py | 5 +++ 12 files changed, 113 insertions(+), 159 deletions(-) delete mode 100644 common/apps/organization_user/migrations/0002_organizationuser_providers.py delete mode 100644 common/apps/organization_user/migrations/0003_alter_organizationuser_providers.py delete mode 100644 common/apps/space/migrations/0002_remove_space_is_multi_tenant_space_created_by.py delete mode 100644 common/apps/space/migrations/0003_space_total_devices.py diff --git a/common/apps/organization/migrations/0001_initial.py b/common/apps/organization/migrations/0001_initial.py index 78182a8..e3c4304 100644 --- a/common/apps/organization/migrations/0001_initial.py +++ b/common/apps/organization/migrations/0001_initial.py @@ -1,7 +1,8 @@ -# Generated by Django 5.0.6 on 2024-06-26 11:34 +# Generated by Django 5.0.6 on 2024-11-20 07:42 import django.db.models.deletion import django_tenants.postgresql_backend.base +import uuid from django.db import migrations, models @@ -14,15 +15,6 @@ class Migration(migrations.Migration): migrations.CreateModel( name="Organization", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), ( "schema_name", models.CharField( @@ -34,6 +26,16 @@ class Migration(migrations.Migration): ], ), ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ("name", models.CharField(max_length=100)), @@ -49,20 +51,21 @@ class Migration(migrations.Migration): migrations.CreateModel( name="Domain", fields=[ + ( + "domain", + models.CharField(db_index=True, max_length=253, unique=True), + ), + ("is_primary", models.BooleanField(db_index=True, default=True)), ( "id", - models.BigAutoField( - auto_created=True, + models.UUIDField( + default=uuid.uuid4, + editable=False, primary_key=True, serialize=False, - verbose_name="ID", + unique=True, ), ), - ( - "domain", - models.CharField(db_index=True, max_length=253, unique=True), - ), - ("is_primary", models.BooleanField(db_index=True, default=True)), ("created_at", models.DateTimeField(auto_now_add=True)), ("updated_at", models.DateTimeField(auto_now=True)), ( diff --git a/common/apps/organization_user/migrations/0001_initial.py b/common/apps/organization_user/migrations/0001_initial.py index 1a3a778..516c296 100644 --- a/common/apps/organization_user/migrations/0001_initial.py +++ b/common/apps/organization_user/migrations/0001_initial.py @@ -1,7 +1,9 @@ -# Generated by Django 5.0.6 on 2024-06-26 12:03 +# Generated by Django 5.0.6 on 2024-11-20 07:42 import common.apps.organization_user.models +import django.contrib.postgres.fields import django.utils.timezone +import uuid from django.db import migrations, models @@ -16,15 +18,6 @@ class Migration(migrations.Migration): migrations.CreateModel( name="OrganizationUser", fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), ("password", models.CharField(max_length=128, verbose_name="password")), ( "last_login", @@ -74,12 +67,38 @@ class Migration(migrations.Migration): default=django.utils.timezone.now, verbose_name="date joined" ), ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), ( "email", models.EmailField( max_length=254, unique=True, verbose_name="email address" ), ), + ( + "providers", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("google", "Google"), + ("None", "None Provider"), + ("space_df", "Space Df"), + ], + max_length=256, + null=True, + ), + default=["None"], + size=None, + ), + ), ("is_owner", models.BooleanField(default=False)), ( "groups", diff --git a/common/apps/organization_user/migrations/0002_organizationuser_providers.py b/common/apps/organization_user/migrations/0002_organizationuser_providers.py deleted file mode 100644 index 26f947e..0000000 --- a/common/apps/organization_user/migrations/0002_organizationuser_providers.py +++ /dev/null @@ -1,26 +0,0 @@ -# Generated by Django 4.2.15 on 2024-08-19 03:34 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("organization_user", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="organizationuser", - name="providers", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField( - choices=[("google", "Google"), ("None", "None Provider")], - max_length=256, - null=True, - ), - default=["None"], - size=None, - ), - ), - ] diff --git a/common/apps/organization_user/migrations/0003_alter_organizationuser_providers.py b/common/apps/organization_user/migrations/0003_alter_organizationuser_providers.py deleted file mode 100644 index f248088..0000000 --- a/common/apps/organization_user/migrations/0003_alter_organizationuser_providers.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.6 on 2024-11-06 04:22 - -import django.contrib.postgres.fields -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("organization_user", "0002_organizationuser_providers"), - ] - - operations = [ - migrations.AlterField( - model_name="organizationuser", - name="providers", - field=django.contrib.postgres.fields.ArrayField( - base_field=models.CharField( - choices=[ - ("google", "Google"), - ("None", "None Provider"), - ("space_df", "Space DF"), - ], - max_length=256, - null=True, - ), - default=["None"], - size=None, - ), - ), - ] diff --git a/common/apps/organization_user/models.py b/common/apps/organization_user/models.py index 39cae5c..c2b67f2 100644 --- a/common/apps/organization_user/models.py +++ b/common/apps/organization_user/models.py @@ -1,3 +1,5 @@ +import uuid + from django.contrib.auth.base_user import BaseUserManager from django.contrib.auth.models import AbstractUser from django.contrib.postgres.fields import ArrayField @@ -48,6 +50,9 @@ def get_by_natural_key(self, username): class OrganizationUser(AbstractUser, SynchronousTenantModel): """User model.""" + id = models.UUIDField( + default=uuid.uuid4, unique=True, primary_key=True, editable=False + ) username = None email = models.EmailField(_("email address"), unique=True) providers = ArrayField( diff --git a/common/apps/refresh_tokens/migrations/0001_initial.py b/common/apps/refresh_tokens/migrations/0001_initial.py index fca390f..78d986c 100644 --- a/common/apps/refresh_tokens/migrations/0001_initial.py +++ b/common/apps/refresh_tokens/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 5.0.6 on 2024-08-01 02:13 +# Generated by Django 5.0.6 on 2024-11-20 07:42 import django.db.models.deletion +import uuid from django.conf import settings from django.db import migrations, models @@ -18,11 +19,12 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.BigAutoField( - auto_created=True, + models.UUIDField( + default=uuid.uuid4, + editable=False, primary_key=True, serialize=False, - verbose_name="ID", + unique=True, ), ), ("created_at", models.DateTimeField(auto_now_add=True)), @@ -52,11 +54,12 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.BigAutoField( - auto_created=True, + models.UUIDField( + default=uuid.uuid4, + editable=False, primary_key=True, serialize=False, - verbose_name="ID", + unique=True, ), ), ("created_at", models.DateTimeField(auto_now_add=True)), diff --git a/common/apps/space/migrations/0001_initial.py b/common/apps/space/migrations/0001_initial.py index fc6f9f5..fdd9601 100644 --- a/common/apps/space/migrations/0001_initial.py +++ b/common/apps/space/migrations/0001_initial.py @@ -1,12 +1,18 @@ -# Generated by Django 5.0.6 on 2024-06-26 10:09 +# Generated by Django 5.0.6 on 2024-11-20 07:42 +import django.core.validators +import django.db.models.deletion +import uuid +from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): initial = True - dependencies = [] + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] operations = [ migrations.CreateModel( @@ -14,11 +20,12 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.BigAutoField( - auto_created=True, + models.UUIDField( + default=uuid.uuid4, + editable=False, primary_key=True, serialize=False, - verbose_name="ID", + unique=True, ), ), ("created_at", models.DateTimeField(auto_now_add=True)), @@ -26,8 +33,24 @@ class Migration(migrations.Migration): ("name", models.CharField(max_length=256)), ("logo", models.CharField(max_length=256)), ("slug_name", models.SlugField(max_length=64, unique=True)), - ("is_multi_tenant", models.BooleanField(default=False)), ("is_active", models.BooleanField(default=True)), + ( + "total_devices", + models.IntegerField( + default=0, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "created_by", + models.ForeignKey( + default=None, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_space", + to=settings.AUTH_USER_MODEL, + ), + ), ], options={ "indexes": [models.Index(fields=["slug_name"], name="slug_name_idx")], diff --git a/common/apps/space/migrations/0002_remove_space_is_multi_tenant_space_created_by.py b/common/apps/space/migrations/0002_remove_space_is_multi_tenant_space_created_by.py deleted file mode 100644 index 3f32e3d..0000000 --- a/common/apps/space/migrations/0002_remove_space_is_multi_tenant_space_created_by.py +++ /dev/null @@ -1,30 +0,0 @@ -# Generated by Django 5.0.6 on 2024-07-12 09:56 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("space", "0001_initial"), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.RemoveField( - model_name="space", - name="is_multi_tenant", - ), - migrations.AddField( - model_name="space", - name="created_by", - field=models.ForeignKey( - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="created_space", - to=settings.AUTH_USER_MODEL, - ), - ), - ] diff --git a/common/apps/space/migrations/0003_space_total_devices.py b/common/apps/space/migrations/0003_space_total_devices.py deleted file mode 100644 index a82aa92..0000000 --- a/common/apps/space/migrations/0003_space_total_devices.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.0.6 on 2024-11-06 02:00 - -import django.core.validators -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("space", "0002_remove_space_is_multi_tenant_space_created_by"), - ] - - operations = [ - migrations.AddField( - model_name="space", - name="total_devices", - field=models.IntegerField( - default=0, validators=[django.core.validators.MinValueValidator(0)] - ), - ), - ] diff --git a/common/apps/space_role/migrations/0001_initial.py b/common/apps/space_role/migrations/0001_initial.py index 83903e5..5af3fd8 100644 --- a/common/apps/space_role/migrations/0001_initial.py +++ b/common/apps/space_role/migrations/0001_initial.py @@ -1,7 +1,8 @@ -# Generated by Django 5.0.6 on 2024-06-26 10:11 +# Generated by Django 5.0.6 on 2024-11-20 07:42 import django.contrib.postgres.fields import django.db.models.deletion +import uuid from django.conf import settings from django.db import migrations, models @@ -20,11 +21,12 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.BigAutoField( - auto_created=True, + models.UUIDField( + default=uuid.uuid4, + editable=False, primary_key=True, serialize=False, - verbose_name="ID", + unique=True, ), ), ("created_at", models.DateTimeField(auto_now_add=True)), @@ -76,11 +78,12 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.BigAutoField( - auto_created=True, + models.UUIDField( + default=uuid.uuid4, + editable=False, primary_key=True, serialize=False, - verbose_name="ID", + unique=True, ), ), ("created_at", models.DateTimeField(auto_now_add=True)), @@ -105,11 +108,12 @@ class Migration(migrations.Migration): fields=[ ( "id", - models.BigAutoField( - auto_created=True, + models.UUIDField( + default=uuid.uuid4, + editable=False, primary_key=True, serialize=False, - verbose_name="ID", + unique=True, ), ), ("created_at", models.DateTimeField(auto_now_add=True)), diff --git a/common/apps/space_role/migrations/0002_create_default_policies.py b/common/apps/space_role/migrations/0002_create_default_policies.py index e9bed6c..9e015cc 100644 --- a/common/apps/space_role/migrations/0002_create_default_policies.py +++ b/common/apps/space_role/migrations/0002_create_default_policies.py @@ -1,5 +1,3 @@ -# Generated by Django 5.0.6 on 2024-06-21 07:20 - from common.apps.space_role.constants import SpacePermission from django.db import migrations diff --git a/common/models/base_model.py b/common/models/base_model.py index 3e15729..555c12e 100644 --- a/common/models/base_model.py +++ b/common/models/base_model.py @@ -1,7 +1,12 @@ +import uuid + from django.db import models class BaseModel(models.Model): + id = models.UUIDField( + default=uuid.uuid4, unique=True, primary_key=True, editable=False + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) From 5eb937439ee0ee0f0820999a18244b581eca879b Mon Sep 17 00:00:00 2001 From: tamdanghuynhkim Date: Mon, 25 Nov 2024 11:35:57 +0700 Subject: [PATCH 14/54] feat: base for admin and serializiers --- common/admin/__init__.py | 0 common/admin/base_admin.py | 12 +++++++++ common/serializers/__init__.py | 0 common/serializers/base_serializers.py | 34 ++++++++++++++++++++++++++ 4 files changed, 46 insertions(+) create mode 100644 common/admin/__init__.py create mode 100644 common/admin/base_admin.py create mode 100644 common/serializers/__init__.py create mode 100644 common/serializers/base_serializers.py diff --git a/common/admin/__init__.py b/common/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/admin/base_admin.py b/common/admin/base_admin.py new file mode 100644 index 0000000..232d076 --- /dev/null +++ b/common/admin/base_admin.py @@ -0,0 +1,12 @@ +from django.contrib import admin + + +class ListDisplayMixin(admin.ModelAdmin): + list_display_exclude = () + + def get_list_display(self, request): + all_fields = [field.name for field in self.model._meta.fields] + list_display = [ + field for field in all_fields if field not in self.list_display_exclude + ] + return list_display diff --git a/common/serializers/__init__.py b/common/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/serializers/base_serializers.py b/common/serializers/base_serializers.py new file mode 100644 index 0000000..49e4db3 --- /dev/null +++ b/common/serializers/base_serializers.py @@ -0,0 +1,34 @@ +from rest_framework import serializers + + +class DynamicSerializerMixin: + """ + A Serializer that takes additional `fields` and `exclude` arguments. + - `fields`: Controls which fields should be included. + - `exclude`: Controls which fields should be excluded. + """ + + def __init__(self, *args, **kwargs): + fields = kwargs.pop("fields", None) + exclude = kwargs.pop("exclude", None) + + super().__init__(*args, **kwargs) + + if fields is not None: + allowed = set(fields) + existing = set(self.fields) + for field_name in existing - allowed: + self.fields.pop(field_name) + + if exclude is not None: + excluded = set(exclude) + for field_name in excluded: + self.fields.pop(field_name, None) + + +class DynamicFieldsSerializer(DynamicSerializerMixin, serializers.Serializer): + pass + + +class DynamicModelSerializer(DynamicSerializerMixin, serializers.ModelSerializer): + pass From 40f80a39f4a5a8e7103c48128de260268e0283b8 Mon Sep 17 00:00:00 2001 From: tamdanghuynhkim Date: Thu, 6 Feb 2025 18:07:41 +0700 Subject: [PATCH 15/54] fix: dont allow none in enum --- common/utils/social_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/utils/social_provider.py b/common/utils/social_provider.py index 3c9b92d..62280f2 100644 --- a/common/utils/social_provider.py +++ b/common/utils/social_provider.py @@ -3,5 +3,5 @@ class SocialProvider(models.TextChoices): GOOGLE = "google" - NONE_PROVIDER = None + NONE_PROVIDER = "" SPACE_DF = "space_df" From 11063db7207fbb153bde5aed69dff4547e668de6 Mon Sep 17 00:00:00 2001 From: pikann Date: Fri, 7 Mar 2025 11:50:20 +0700 Subject: [PATCH 16/54] chore: remove authentication --- common/apps/refresh_tokens/jwts.py | 7 -- common/apps/refresh_tokens/services.py | 5 +- common/permissions/constants.py | 5 -- common/permissions/permission_classes.py | 59 -------------- common/permissions/permission_condition.py | 90 ---------------------- common/views/space.py | 17 ---- 6 files changed, 4 insertions(+), 179 deletions(-) delete mode 100644 common/permissions/constants.py delete mode 100644 common/permissions/permission_condition.py diff --git a/common/apps/refresh_tokens/jwts.py b/common/apps/refresh_tokens/jwts.py index 87c99ab..e1a28af 100644 --- a/common/apps/refresh_tokens/jwts.py +++ b/common/apps/refresh_tokens/jwts.py @@ -28,9 +28,6 @@ def encode(self, payload): return token - def set_iss(self, issuer: str): - self.issuer = issuer - token_backend = CustomTokenBackend( api_settings.ALGORITHM, @@ -74,7 +71,3 @@ def token_backend(self): if self._token_backend is None: self._token_backend = token_backend return self._token_backend - - def set_iss(self, claim: str = "iss", issuer=None) -> None: - self.token_backend.set_iss(issuer=issuer) - self.payload[claim] = issuer diff --git a/common/apps/refresh_tokens/services.py b/common/apps/refresh_tokens/services.py index 01946b9..6cbc071 100644 --- a/common/apps/refresh_tokens/services.py +++ b/common/apps/refresh_tokens/services.py @@ -12,7 +12,10 @@ def create_jwt_tokens(user, issuer=None, **kwargs): refresh = JWTRefreshToken.for_user(user) if issuer: domain = update_subdomain(settings.HOST, issuer.slug_name) - refresh.set_iss("iss", domain) + else: + domain = settings.HOST + refresh.payload["iss"] = domain + token_family = RefreshTokenFamily(user=user) token_family.save() RefreshToken( diff --git a/common/permissions/constants.py b/common/permissions/constants.py deleted file mode 100644 index 2f65e2a..0000000 --- a/common/permissions/constants.py +++ /dev/null @@ -1,5 +0,0 @@ -POST_METHOD = "POST" -UPDATE_METHODS = ("PUT", "PATCH") -DELETE_METHOD = "DELETE" - -NONE_OBJECT = object() diff --git a/common/permissions/permission_classes.py b/common/permissions/permission_classes.py index d8e6bfb..d67b8ec 100644 --- a/common/permissions/permission_classes.py +++ b/common/permissions/permission_classes.py @@ -1,68 +1,9 @@ from django.conf import settings -from django.contrib.auth import get_user_model -from rest_framework.exceptions import ParseError from rest_framework.permissions import BasePermission -from common.apps.space_role.models import SpacePolicy - -User = get_user_model() - - -def is_method(methods): - class IsMethodRequest(BasePermission): - def has_permission(self, request, view): - if isinstance(methods, str): - return request.method == methods - elif isinstance(methods, list) or isinstance(methods, tuple): - return request.method in methods - return False - - return IsMethodRequest - - -def has_space_permission_access(permission): - """ - Allows access only to users who have specific space permissions. - """ - - class HasPermissionAccess(BasePermission): - __permission = permission - - def has_permission(self, request, view): - space_slug_name = request.headers.get("X-Space", None) - if space_slug_name is None: - raise ParseError("X-Space header is required") - - policies = SpacePolicy.objects.filter( - spacerole__space__slug_name=space_slug_name, - spacerole__space_role_user__organization_user_id=request.user.id, - ).distinct() - return self.__permission in [ - policy_permission - for policy in policies - for policy_permission in policy.permissions - ] - - return HasPermissionAccess - - -class IsOwner(BasePermission): - def has_object_permission(self, request, view, obj): - try: - user = User.objects.get(id=request.user.id) - return user.organization_users.is_owner - except User.DoesNotExist: - return False - class HasAPIKey(BasePermission): def has_permission(self, request, view): spacedf_key = request.headers.get("x-api-key", None) # TODO: need model for this return settings.ROOT_API_KEY == spacedf_key - - -class HasSpaceName(BasePermission): - def has_permission(self, request, view): - space_slug_name = request.headers.get("X-Space", None) - return bool(space_slug_name) diff --git a/common/permissions/permission_condition.py b/common/permissions/permission_condition.py deleted file mode 100644 index c55eaee..0000000 --- a/common/permissions/permission_condition.py +++ /dev/null @@ -1,90 +0,0 @@ -import inspect -import operator - -from common.permissions.constants import NONE_OBJECT - - -def _is_permission_factory(obj): - return inspect.isclass(obj) or inspect.isfunction(obj) - - -class PermissionCondition(object): - """ - Provides a simple way to define complex and multi-depth - (with logic operators) permissions tree. - """ - - @classmethod - def And(cls, *perms_or_conds): - return cls(reduce_op=operator.and_, lazy_until=False, *perms_or_conds) - - @classmethod - def Or(cls, *perms_or_conds): - return cls(reduce_op=operator.or_, lazy_until=True, *perms_or_conds) - - @classmethod - def Not(cls, *perms_or_conds): - return cls(negated=True, *perms_or_conds) - - def __init__(self, *perms_or_conds, **kwargs): - self.perms_or_conds = perms_or_conds - self.reduce_op = kwargs.get("reduce_op", operator.and_) - self.lazy_until = kwargs.get("lazy_until", False) - self.negated = kwargs.get("negated") - - def evaluate_permissions(self, permission_name, *args, **kwargs): - reduced_result = NONE_OBJECT - - for condition in self.perms_or_conds: - if hasattr(condition, "evaluate_permissions"): - result = condition.evaluate_permissions( - permission_name, *args, **kwargs - ) - else: - if _is_permission_factory(condition): - condition = condition() - result = getattr(condition, permission_name)(*args, **kwargs) - - # In some cases permission may not have explicit return statement - if result is None: - result = False - # As well as can return Django CallableBool - elif callable(result): - result = result() - - if reduced_result is NONE_OBJECT: - reduced_result = result - else: - reduced_result = self.reduce_op(reduced_result, result) - - if self.lazy_until is not None and self.lazy_until is reduced_result: - break - - if reduced_result is not NONE_OBJECT: - return not reduced_result if self.negated else reduced_result - - return False - - def has_object_permission(self, request, view, obj): - return self.evaluate_permissions("has_object_permission", request, view, obj) - - def has_permission(self, request, view): - return self.evaluate_permissions("has_permission", request, view) - - def __or__(self, perm_or_cond): - return self.Or(self, perm_or_cond) - - def __ior__(self, perm_or_cond): - return self.Or(self, perm_or_cond) - - def __and__(self, perm_or_cond): - return self.And(self, perm_or_cond) - - def __iand__(self, perm_or_cond): - return self.And(self, perm_or_cond) - - def __invert__(self): - return self.Not(self) - - def __call__(self): - return self diff --git a/common/views/space.py b/common/views/space.py index 2886b0b..37a01fa 100644 --- a/common/views/space.py +++ b/common/views/space.py @@ -49,7 +49,6 @@ class SpaceCreateAPIView(mixins.CreateModelMixin, SpaceAPIView): def perform_create(self, serializer): self.create_with_space(serializer) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def post(self, request, *args, **kwargs): return self.create(request, *args, **kwargs) @@ -59,7 +58,6 @@ class SpaceListAPIView(mixins.ListModelMixin, SpaceAPIView): Concrete view for listing a queryset of space. """ - @swagger_auto_schema(manual_parameters=get_space_header_params()) def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) @@ -69,7 +67,6 @@ class SpaceRetrieveAPIView(mixins.RetrieveModelMixin, SpaceAPIView): Concrete view for retrieving a model instance of space. """ - @swagger_auto_schema(manual_parameters=get_space_header_params()) def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) @@ -79,7 +76,6 @@ class SpaceDestroyAPIView(mixins.DestroyModelMixin, SpaceAPIView): Concrete view for deleting a model instance of space. """ - @swagger_auto_schema(manual_parameters=get_space_header_params()) def delete(self, request, *args, **kwargs): return self.destroy(request, *args, **kwargs) @@ -89,11 +85,9 @@ class SpaceUpdateAPIView(mixins.UpdateModelMixin, SpaceAPIView): Concrete view for updating a model instance of space. """ - @swagger_auto_schema(manual_parameters=get_space_header_params()) def put(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def patch(self, request, *args, **kwargs): return self.partial_update(request, *args, **kwargs) @@ -108,11 +102,9 @@ class SpaceListCreateAPIView( def perform_create(self, serializer): self.create_with_space(serializer) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def post(self, request, *args, **kwargs): return self.create(request, *args, **kwargs) @@ -124,15 +116,12 @@ class SpaceRetrieveUpdateAPIView( Concrete view for retrieving, updating a model instance of space. """ - @swagger_auto_schema(manual_parameters=get_space_header_params()) def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def put(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def patch(self, request, *args, **kwargs): return self.partial_update(request, *args, **kwargs) @@ -144,11 +133,9 @@ class SpaceRetrieveDestroyAPIView( Concrete view for retrieving or deleting a model instance of space. """ - @swagger_auto_schema(manual_parameters=get_space_header_params()) def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def delete(self, request, *args, **kwargs): return self.destroy(request, *args, **kwargs) @@ -163,18 +150,14 @@ class SpaceRetrieveUpdateDestroyAPIView( Concrete view for retrieving, updating or deleting a model instance of space. """ - @swagger_auto_schema(manual_parameters=get_space_header_params()) def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def put(self, request, *args, **kwargs): return self.update(request, *args, **kwargs) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def patch(self, request, *args, **kwargs): return self.partial_update(request, *args, **kwargs) - @swagger_auto_schema(manual_parameters=get_space_header_params()) def delete(self, request, *args, **kwargs): return self.destroy(request, *args, **kwargs) From 8922825d51579174a78d52eaaa60f5dec1b5e0ca Mon Sep 17 00:00:00 2001 From: pikann Date: Fri, 7 Mar 2025 16:45:01 +0700 Subject: [PATCH 17/54] feat: implement add space command --- common/apps/space/management/__init__.py | 0 .../space/management/commands/__init__.py | 0 .../space/management/commands/create_space.py | 31 +++++++++++++++++++ common/apps/space/migrations/0001_initial.py | 19 ++---------- common/apps/space/models.py | 11 +------ 5 files changed, 35 insertions(+), 26 deletions(-) create mode 100644 common/apps/space/management/__init__.py create mode 100644 common/apps/space/management/commands/__init__.py create mode 100644 common/apps/space/management/commands/create_space.py diff --git a/common/apps/space/management/__init__.py b/common/apps/space/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/apps/space/management/commands/__init__.py b/common/apps/space/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/apps/space/management/commands/create_space.py b/common/apps/space/management/commands/create_space.py new file mode 100644 index 0000000..243f437 --- /dev/null +++ b/common/apps/space/management/commands/create_space.py @@ -0,0 +1,31 @@ +from django.core.management.base import BaseCommand +from django_tenants.utils import schema_context + +from common.apps.space.models import Space + + +class Command(BaseCommand): + help = "Create a new space" + + def add_arguments(self, parser): + parser.add_argument("--organization", type=str, help="Organization slug") + parser.add_argument("--name", type=str, help="Space name") + parser.add_argument("--logo", type=str, help="Space logo") + parser.add_argument("--slug_name", type=str, help="Space slug name") + parser.add_argument("--created_by", type=str, help="Space creator UUID") + + def handle(self, *args, **kwargs): + organization = kwargs.get("organization") or input("Organization slug: ") + name = kwargs.get("name") or input("Name: ") + logo = kwargs.get("logo") or input("Logo: ") + slug_name = kwargs.get("slug_name") or input("Slug name: ") + created_by = kwargs.get("created_by") or input("Creator UUID: ") + + with schema_context(organization): + space = Space.objects.create( + name=name, logo=logo, slug_name=slug_name, created_by=created_by + ) + + self.stdout.write( + self.style.SUCCESS(f"Space {space.name} created successfully") + ) diff --git a/common/apps/space/migrations/0001_initial.py b/common/apps/space/migrations/0001_initial.py index fdd9601..44adaae 100644 --- a/common/apps/space/migrations/0001_initial.py +++ b/common/apps/space/migrations/0001_initial.py @@ -1,18 +1,14 @@ -# Generated by Django 5.0.6 on 2024-11-20 07:42 +# Generated by Django 5.0.6 on 2025-03-07 08:46 import django.core.validators -import django.db.models.deletion import uuid -from django.conf import settings from django.db import migrations, models class Migration(migrations.Migration): initial = True - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] + dependencies = [] operations = [ migrations.CreateModel( @@ -41,16 +37,7 @@ class Migration(migrations.Migration): validators=[django.core.validators.MinValueValidator(0)], ), ), - ( - "created_by", - models.ForeignKey( - default=None, - null=True, - on_delete=django.db.models.deletion.SET_NULL, - related_name="created_space", - to=settings.AUTH_USER_MODEL, - ), - ), + ("created_by", models.UUIDField()), ], options={ "indexes": [models.Index(fields=["slug_name"], name="slug_name_idx")], diff --git a/common/apps/space/models.py b/common/apps/space/models.py index 64710f6..9a51b15 100644 --- a/common/apps/space/models.py +++ b/common/apps/space/models.py @@ -1,12 +1,9 @@ -from django.contrib.auth import get_user_model from django.core.validators import MinValueValidator from django.db import models from common.models.base_model import BaseModel from common.models.synchronous_model import SynchronousTenantModel -User = get_user_model() - class Space(BaseModel, SynchronousTenantModel): name = models.CharField(max_length=256) @@ -14,13 +11,7 @@ class Space(BaseModel, SynchronousTenantModel): slug_name = models.SlugField(max_length=64, unique=True) is_active = models.BooleanField(default=True) total_devices = models.IntegerField(default=0, validators=[MinValueValidator(0)]) - created_by = models.ForeignKey( - User, - related_name="created_space", - on_delete=models.SET_NULL, - default=None, - null=True, - ) + created_by = models.UUIDField() class Meta: indexes = [ From 18b136bccc76290defd2023c8346b234a0f20e7d Mon Sep 17 00:00:00 2001 From: pikann Date: Mon, 10 Mar 2025 15:33:56 +0700 Subject: [PATCH 18/54] chore: reformat source code --- common/apps/refresh_tokens/serializers.py | 2 +- common/apps/refresh_tokens/services.py | 2 +- common/swagger/params.py | 20 -------------------- common/views/space.py | 2 -- 4 files changed, 2 insertions(+), 24 deletions(-) diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index f2c18f5..e54d774 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -96,7 +96,7 @@ def validate(self, attrs): params = { "access_token": refresh.access_token, "user_id": refresh.payload["user_id"], - **self.context["access_token_handler_params"] + **self.context["access_token_handler_params"], } access = self.context["access_token_handler"](**params) data = {"access": str(access)} diff --git a/common/apps/refresh_tokens/services.py b/common/apps/refresh_tokens/services.py index 6cbc071..4f2ab7f 100644 --- a/common/apps/refresh_tokens/services.py +++ b/common/apps/refresh_tokens/services.py @@ -15,7 +15,7 @@ def create_jwt_tokens(user, issuer=None, **kwargs): else: domain = settings.HOST refresh.payload["iss"] = domain - + token_family = RefreshTokenFamily(user=user) token_family.save() RefreshToken( diff --git a/common/swagger/params.py b/common/swagger/params.py index 252321c..9fb79f1 100644 --- a/common/swagger/params.py +++ b/common/swagger/params.py @@ -1,5 +1,4 @@ from drf_yasg import openapi -from drf_yasg.inspectors import SwaggerAutoSchema def get_space_header_params(required=True): @@ -13,22 +12,3 @@ def get_space_header_params(required=True): default="", ) ] - - -class CustomSwaggerAutoSchema(SwaggerAutoSchema): - def get_operation(self, operation_keys=None): - operation = super().get_operation(operation_keys) - api_key_param = openapi.Parameter( - name="x-api-key", - in_=openapi.IN_HEADER, - type=openapi.TYPE_STRING, - required=True, - description="API Key for authentication", - ) - if any( - path in self.path for path in ["api/health", "docs", "api/auth", "admin"] - ): - api_key_param["required"] = False - - operation["parameters"] = operation.get("parameters", []) + [api_key_param] - return operation diff --git a/common/views/space.py b/common/views/space.py index 37a01fa..f135d20 100644 --- a/common/views/space.py +++ b/common/views/space.py @@ -1,10 +1,8 @@ -from drf_yasg.utils import swagger_auto_schema from rest_framework import mixins from rest_framework.exceptions import ParseError from rest_framework.generics import GenericAPIView from common.apps.space.models import Space -from common.swagger.params import get_space_header_params class SpaceAPIView(GenericAPIView): From 674d15d74cf5890539ad469d1bab01f2734625ff Mon Sep 17 00:00:00 2001 From: pikann Date: Tue, 11 Mar 2025 17:01:16 +0700 Subject: [PATCH 19/54] fix: fix error cannot switch organization --- common/apps/refresh_tokens/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/apps/refresh_tokens/serializers.py b/common/apps/refresh_tokens/serializers.py index e54d774..c8d4ec2 100644 --- a/common/apps/refresh_tokens/serializers.py +++ b/common/apps/refresh_tokens/serializers.py @@ -72,7 +72,7 @@ class CustomTokenRefreshSerializer(TokenRefreshSerializer): def validate(self, attrs): refresh = self.token_class(attrs["refresh"]) - if hasattr(self.context["request"], "tenant"): + if "request" in self.context and hasattr(self.context["request"], "tenant"): refresh.check_iss() refresh_token_obj = ( From 945d452975295fa71bf51ad92d01c6cdd8e4dd86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C5=A9=20Thanh=20T=C3=A2m?= Date: Tue, 11 Mar 2025 17:03:03 +0700 Subject: [PATCH 20/54] feat:send otp email --- common/utils/send_otp_email.py | 35 ++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 common/utils/send_otp_email.py diff --git a/common/utils/send_otp_email.py b/common/utils/send_otp_email.py new file mode 100644 index 0000000..8cf66a1 --- /dev/null +++ b/common/utils/send_otp_email.py @@ -0,0 +1,35 @@ +import random +import string +from django.core.mail import send_mail +from django.core.cache import cache # Import Django's Redis cache +from django.conf import settings + +OTP_EXPIRY_SECONDS = 600 # 10 minutes + +def generate_otp(length=6): + """Generate a 6-digit OTP.""" + return ''.join(random.choices(string.digits, k=length)) + +def send_otp_email(user_email): + """Send OTP to user and store it in Redis.""" + otp_code = generate_otp() + + # Store OTP in Redis with a 10-minute expiration + cache.set(f"otp_{user_email}", otp_code, timeout=OTP_EXPIRY_SECONDS) + + # Email content + subject = "Your One-Time Sign-In Code" + message = f""" + Hello, + + Your one-time sign-in code is: {otp_code} + + This code will expire in 10 minutes. + + Best regards, + Digital Fortress + """ + + send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [user_email]) + + return otp_code From 475b70b2942bd45e033f05375fd10b3c9210fa56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C5=A9=20Thanh=20T=C3=A2m?= Date: Tue, 11 Mar 2025 17:18:08 +0700 Subject: [PATCH 21/54] chore: fix flake8 --- common/utils/send_otp_email.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/common/utils/send_otp_email.py b/common/utils/send_otp_email.py index 8cf66a1..902369f 100644 --- a/common/utils/send_otp_email.py +++ b/common/utils/send_otp_email.py @@ -1,19 +1,22 @@ import random import string -from django.core.mail import send_mail -from django.core.cache import cache # Import Django's Redis cache + from django.conf import settings +from django.core.cache import cache # Import Django's Redis cache +from django.core.mail import send_mail OTP_EXPIRY_SECONDS = 600 # 10 minutes + def generate_otp(length=6): """Generate a 6-digit OTP.""" - return ''.join(random.choices(string.digits, k=length)) + return "".join(random.choices(string.digits, k=length)) + def send_otp_email(user_email): """Send OTP to user and store it in Redis.""" otp_code = generate_otp() - + # Store OTP in Redis with a 10-minute expiration cache.set(f"otp_{user_email}", otp_code, timeout=OTP_EXPIRY_SECONDS) From 569191858f4f35e22dadea538703cc80e4100eba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C5=A9=20Thanh=20T=C3=A2m?= Date: Tue, 11 Mar 2025 17:21:38 +0700 Subject: [PATCH 22/54] chore: fix bandit error --- common/utils/send_otp_email.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common/utils/send_otp_email.py b/common/utils/send_otp_email.py index 902369f..6c04e44 100644 --- a/common/utils/send_otp_email.py +++ b/common/utils/send_otp_email.py @@ -1,4 +1,4 @@ -import random +import secrets import string from django.conf import settings @@ -10,8 +10,7 @@ def generate_otp(length=6): """Generate a 6-digit OTP.""" - return "".join(random.choices(string.digits, k=length)) - + return "".join(secrets.choice(string.digits) for _ in range(length)) def send_otp_email(user_email): """Send OTP to user and store it in Redis.""" From e8daf48d0452b164f9a14351886d33323c5c2ee7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C5=A9=20Thanh=20T=C3=A2m?= Date: Tue, 11 Mar 2025 17:23:33 +0700 Subject: [PATCH 23/54] chore: fix flake8 again --- common/utils/send_otp_email.py | 1 + 1 file changed, 1 insertion(+) diff --git a/common/utils/send_otp_email.py b/common/utils/send_otp_email.py index 6c04e44..3dbcc4b 100644 --- a/common/utils/send_otp_email.py +++ b/common/utils/send_otp_email.py @@ -12,6 +12,7 @@ def generate_otp(length=6): """Generate a 6-digit OTP.""" return "".join(secrets.choice(string.digits) for _ in range(length)) + def send_otp_email(user_email): """Send OTP to user and store it in Redis.""" otp_code = generate_otp() From fe8b4599b8adbcce95057df3d8135285ca40ed60 Mon Sep 17 00:00:00 2001 From: pikann Date: Thu, 13 Mar 2025 14:59:37 +0700 Subject: [PATCH 24/54] feat: update create organization command --- .../apps/organization/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/create_organization.py | 40 +++++++++++++++++++ .../space/management/commands/create_space.py | 18 +++++---- 4 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 common/apps/organization/management/__init__.py create mode 100644 common/apps/organization/management/commands/__init__.py create mode 100644 common/apps/organization/management/commands/create_organization.py diff --git a/common/apps/organization/management/__init__.py b/common/apps/organization/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/apps/organization/management/commands/__init__.py b/common/apps/organization/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/common/apps/organization/management/commands/create_organization.py b/common/apps/organization/management/commands/create_organization.py new file mode 100644 index 0000000..4f3005f --- /dev/null +++ b/common/apps/organization/management/commands/create_organization.py @@ -0,0 +1,40 @@ +from django.conf import settings +from django.core.management.base import BaseCommand + +from common.apps.organization.models import Domain, Organization + + +class Command(BaseCommand): + help = "Create a new space" + + def add_arguments(self, parser): + parser.add_argument("--name", type=str, help="Organization name") + parser.add_argument("--slug_name", type=str, help="Organization slug name") + parser.add_argument( + "--is_multi_tenant", type=bool, help="True if organization is multi-tenant" + ) + + def handle(self, *args, **kwargs): + name = kwargs.get("name") or input("Name [test]: ") or "test" + slug_name = kwargs.get("slug_name") or input("Slug name [test]: ") or "test" + is_multi_tenant = ( + kwargs.get("is_multi_tenant") or input("Is multi-tenant [False]: ") or False + ) + + organization = Organization( + schema_name=slug_name, + name=name, + slug_name=slug_name, + is_active=True, + is_multi_tenant=is_multi_tenant, + ) + organization.save() + Domain( + domain=f"{slug_name}.{settings.DEFAULT_TENANT_HOST}", + tenant=organization, + is_primary=True, + ).save() + + self.stdout.write( + self.style.SUCCESS(f"Organization {organization.name} created successfully") + ) diff --git a/common/apps/space/management/commands/create_space.py b/common/apps/space/management/commands/create_space.py index 243f437..b968a25 100644 --- a/common/apps/space/management/commands/create_space.py +++ b/common/apps/space/management/commands/create_space.py @@ -10,20 +10,24 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument("--organization", type=str, help="Organization slug") parser.add_argument("--name", type=str, help="Space name") - parser.add_argument("--logo", type=str, help="Space logo") parser.add_argument("--slug_name", type=str, help="Space slug name") parser.add_argument("--created_by", type=str, help="Space creator UUID") def handle(self, *args, **kwargs): - organization = kwargs.get("organization") or input("Organization slug: ") - name = kwargs.get("name") or input("Name: ") - logo = kwargs.get("logo") or input("Logo: ") - slug_name = kwargs.get("slug_name") or input("Slug name: ") - created_by = kwargs.get("created_by") or input("Creator UUID: ") + organization = ( + kwargs.get("organization") or input("Organization slug [test]: ") or "test" + ) + name = kwargs.get("name") or input("Name [test]: ") or "test" + slug_name = kwargs.get("slug_name") or input("Slug name [test]: ") or "test" + created_by = ( + kwargs.get("created_by") + or input("Creator UUID [05931ac6-d2c4-4fed-818f-13a0ee506e7e]: ") + or "05931ac6-d2c4-4fed-818f-13a0ee506e7e" + ) with schema_context(organization): space = Space.objects.create( - name=name, logo=logo, slug_name=slug_name, created_by=created_by + name=name, logo="", slug_name=slug_name, created_by=created_by ) self.stdout.write( From e14fcda37a1aed7134df1b3fe392e9309bc7c0aa Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Wed, 19 Mar 2025 11:21:50 +0700 Subject: [PATCH 25/54] feature: update organization user model --- .../0002_organizationuser_avatar_and_more.py | 51 +++++++++++++++++++ common/apps/organization_user/models.py | 9 ++++ 2 files changed, 60 insertions(+) create mode 100644 common/apps/organization_user/migrations/0002_organizationuser_avatar_and_more.py diff --git a/common/apps/organization_user/migrations/0002_organizationuser_avatar_and_more.py b/common/apps/organization_user/migrations/0002_organizationuser_avatar_and_more.py new file mode 100644 index 0000000..7f6b020 --- /dev/null +++ b/common/apps/organization_user/migrations/0002_organizationuser_avatar_and_more.py @@ -0,0 +1,51 @@ +# Generated by Django 5.0.6 on 2025-03-18 04:40 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("organization_user", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="organizationuser", + name="avatar", + field=models.CharField(blank=True, default="", max_length=256), + ), + migrations.AddField( + model_name="organizationuser", + name="company_name", + field=models.CharField(blank=True, default="", max_length=256), + ), + migrations.AddField( + model_name="organizationuser", + name="location", + field=models.CharField(blank=True, default="", max_length=256), + ), + migrations.AddField( + model_name="organizationuser", + name="title", + field=models.CharField(blank=True, default="", max_length=256), + ), + migrations.AlterField( + model_name="organizationuser", + name="providers", + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("google", "Google"), + ("", "None Provider"), + ("space_df", "Space Df"), + ], + max_length=256, + null=True, + ), + default=[""], + size=None, + ), + ), + ] diff --git a/common/apps/organization_user/models.py b/common/apps/organization_user/models.py index c2b67f2..c204846 100644 --- a/common/apps/organization_user/models.py +++ b/common/apps/organization_user/models.py @@ -61,6 +61,11 @@ class OrganizationUser(AbstractUser, SynchronousTenantModel): ) is_owner = models.BooleanField(default=False) + title = models.CharField(max_length=256, blank=True, default="") + avatar = models.CharField(max_length=256, blank=True, default="") + location = models.CharField(max_length=256, blank=True, default="") + company_name = models.CharField(max_length=256, blank=True, default="") + USERNAME_FIELD = "email" REQUIRED_FIELDS = [] @@ -72,4 +77,8 @@ class OrganizationUser(AbstractUser, SynchronousTenantModel): "last_name", "email", "is_owner", + "title", + "avatar", + "location", + "company_name", ] From 743d4691b6ad05b6a10a9d1e95c3967d8347acef Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Sun, 30 Mar 2025 14:47:53 +0700 Subject: [PATCH 26/54] fix: handle exception --- common/utils/send_otp_email.py | 43 +++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/common/utils/send_otp_email.py b/common/utils/send_otp_email.py index 3dbcc4b..ad9ffb9 100644 --- a/common/utils/send_otp_email.py +++ b/common/utils/send_otp_email.py @@ -1,9 +1,10 @@ import secrets +import smtplib import string -from django.conf import settings from django.core.cache import cache # Import Django's Redis cache from django.core.mail import send_mail +from rest_framework.exceptions import ValidationError OTP_EXPIRY_SECONDS = 600 # 10 minutes @@ -13,26 +14,36 @@ def generate_otp(length=6): return "".join(secrets.choice(string.digits) for _ in range(length)) -def send_otp_email(user_email): +def send_otp_email(sender, user_email): """Send OTP to user and store it in Redis.""" - otp_code = generate_otp() + try: + otp_code = generate_otp() - # Store OTP in Redis with a 10-minute expiration - cache.set(f"otp_{user_email}", otp_code, timeout=OTP_EXPIRY_SECONDS) + # Store OTP in Redis with a 10-minute expiration + cache.set(f"otp_{user_email}", otp_code, timeout=OTP_EXPIRY_SECONDS) - # Email content - subject = "Your One-Time Sign-In Code" - message = f""" - Hello, + # Email content + subject = "Your One-Time Sign-In Code" + message = f""" + Hello, - Your one-time sign-in code is: {otp_code} + Your one-time sign-in code is: {otp_code} - This code will expire in 10 minutes. + This code will expire in 10 minutes. - Best regards, - Digital Fortress - """ + Best regards, + Digital Fortress + """ - send_mail(subject, message, settings.DEFAULT_FROM_EMAIL, [user_email]) + send_mail(subject, message, sender, [user_email]) - return otp_code + return otp_code + + except smtplib.SMTPDataError: + raise ValidationError({"error": "Email address is not verified."}) + + except smtplib.SMTPException as e: + raise ValidationError({"error": str(e)}) + + except Exception as e: + raise ValidationError({"error": f"Unexpected Error: {e}"}) From b8f4568e50df4c20714accdc468a42a07ec3fa3f Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Wed, 2 Apr 2025 21:04:42 +0700 Subject: [PATCH 27/54] fix: update code again --- common/utils/send_email.py | 26 ++++++++++++++++++ common/utils/send_otp_email.py | 49 ---------------------------------- 2 files changed, 26 insertions(+), 49 deletions(-) create mode 100644 common/utils/send_email.py delete mode 100644 common/utils/send_otp_email.py diff --git a/common/utils/send_email.py b/common/utils/send_email.py new file mode 100644 index 0000000..db33f99 --- /dev/null +++ b/common/utils/send_email.py @@ -0,0 +1,26 @@ +import smtplib + +from django.core.mail import send_mail +from rest_framework.exceptions import ValidationError + + +def send_email(sender, user_email, subject, message): + """Send email to user and store it in Redis.""" + try: + send_mail( + subject=subject, + message="", + from_email=sender, + recipient_list=user_email, + html_message=message, + fail_silently=False, + ) + + except smtplib.SMTPDataError: + raise ValidationError({"error": "Email address is not verified."}) + + except smtplib.SMTPException as e: + raise ValidationError({"error": str(e)}) + + except Exception as e: + raise ValidationError({"error": f"Unexpected Error: {e}"}) diff --git a/common/utils/send_otp_email.py b/common/utils/send_otp_email.py deleted file mode 100644 index ad9ffb9..0000000 --- a/common/utils/send_otp_email.py +++ /dev/null @@ -1,49 +0,0 @@ -import secrets -import smtplib -import string - -from django.core.cache import cache # Import Django's Redis cache -from django.core.mail import send_mail -from rest_framework.exceptions import ValidationError - -OTP_EXPIRY_SECONDS = 600 # 10 minutes - - -def generate_otp(length=6): - """Generate a 6-digit OTP.""" - return "".join(secrets.choice(string.digits) for _ in range(length)) - - -def send_otp_email(sender, user_email): - """Send OTP to user and store it in Redis.""" - try: - otp_code = generate_otp() - - # Store OTP in Redis with a 10-minute expiration - cache.set(f"otp_{user_email}", otp_code, timeout=OTP_EXPIRY_SECONDS) - - # Email content - subject = "Your One-Time Sign-In Code" - message = f""" - Hello, - - Your one-time sign-in code is: {otp_code} - - This code will expire in 10 minutes. - - Best regards, - Digital Fortress - """ - - send_mail(subject, message, sender, [user_email]) - - return otp_code - - except smtplib.SMTPDataError: - raise ValidationError({"error": "Email address is not verified."}) - - except smtplib.SMTPException as e: - raise ValidationError({"error": str(e)}) - - except Exception as e: - raise ValidationError({"error": f"Unexpected Error: {e}"}) From e616cefcbaf1744056caf8f49fe6d949f16961ac Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 3 Apr 2025 13:35:37 +0700 Subject: [PATCH 28/54] fix: issue static url --- common/middlewares/tenant_middleware.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/common/middlewares/tenant_middleware.py b/common/middlewares/tenant_middleware.py index b1642be..2e34b42 100644 --- a/common/middlewares/tenant_middleware.py +++ b/common/middlewares/tenant_middleware.py @@ -10,6 +10,9 @@ def process_request(self, request): # Connection needs first to be at the public schema, as this is where # the tenant metadata is stored. + if request.path.startswith(settings.STATIC_URL): + return + if not request.path.startswith(tuple(settings.PUBLIC_PATHS)): connection.set_schema_to_public() try: From cd68e6b1436d253efb4f5b577633bd57135a11bd Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 11 Apr 2025 08:43:33 +0700 Subject: [PATCH 29/54] feature: implement login with google --- common/apps/oauth2/serializers.py | 4 +++ common/apps/oauth2/views.py | 57 ++++++++++++++++++++++++++++++- common/utils/encoder.py | 12 +++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 common/utils/encoder.py diff --git a/common/apps/oauth2/serializers.py b/common/apps/oauth2/serializers.py index 11626eb..5b242e9 100644 --- a/common/apps/oauth2/serializers.py +++ b/common/apps/oauth2/serializers.py @@ -4,3 +4,7 @@ class OauthLoginSerializer(serializers.Serializer): authorization_code = serializers.CharField() code_verifier = serializers.CharField() + + +class CodeLoginSerializer(serializers.Serializer): + authorization_code = serializers.CharField() diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index 43e5e20..4c2111e 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -1,12 +1,16 @@ import logging from operator import itemgetter +import requests +from django.conf import settings +from django.shortcuts import redirect from rest_framework import generics, status from rest_framework.exceptions import ParseError from rest_framework.permissions import AllowAny from rest_framework.response import Response -from common.apps.oauth2.serializers import OauthLoginSerializer +from common.apps.oauth2.serializers import CodeLoginSerializer, OauthLoginSerializer +from common.utils.encoder import decode_from_base64 from common.utils.oauth2 import get_access_token, handle_access_token @@ -31,3 +35,54 @@ def post(self, request, *args, **kwargs): logging.exception(e) raise ParseError(detail="Bad request") return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class GoogleLoginCallbackView(generics.RetrieveAPIView): + + def get(self, request): + code = request.GET.get("code") + state = request.GET.get("state") + + if not code or not state: + return Response({"error": "Missing code or state"}, status=400) + + try: + state_data = decode_from_base64(state) + callback_url = state_data["callback_url"] + except Exception as e: + return Response({"error": str(e)}, status=400) + fe_redirect_url = f"{callback_url}?code={code}&state={state}" + return redirect(fe_redirect_url) + + +class GoogleLoginTokenView(generics.CreateAPIView): + serializer_class = CodeLoginSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + authorization_code = serializer.validated_data["authorization_code"] + provider_settings = settings.SOCIALACCOUNT_PROVIDERS.get("google", {}).get( + "APP" + ) + + token_url = settings.OAUTH_CLIENTS["GOOGLE"]["TOKEN_URL"] + data = { + "code": authorization_code, + "client_id": provider_settings.get("client_id"), + "client_secret": provider_settings.get("secret"), + "redirect_uri": settings.OAUTH_CLIENTS["GOOGLE"]["CALLBACK_URL"], + "grant_type": "authorization_code", + } + + token_resp = requests.post(token_url, data=data, timeout=5) + if token_resp.status_code != 200: + return Response( + {"error": "Failed to get token", "detail": token_resp.json()}, + status=status.HTTP_400_BAD_REQUEST, + ) + + token_data = token_resp.json() + access_token = token_data.get("access_token") + + return handle_access_token(access_token=access_token, provider="GOOGLE") diff --git a/common/utils/encoder.py b/common/utils/encoder.py new file mode 100644 index 0000000..68d9150 --- /dev/null +++ b/common/utils/encoder.py @@ -0,0 +1,12 @@ +import base64 +import json + + +def encode_to_base64(data: dict) -> str: + json_str = json.dumps(data) + return base64.urlsafe_b64encode(json_str.encode()).decode() + + +def decode_from_base64(encoded: str) -> dict: + json_str = base64.urlsafe_b64decode(encoded.encode()).decode() + return json.loads(json_str) From e190562c8db610833e174ba9fe9402eda811ff2d Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 17 Apr 2025 08:37:46 +0700 Subject: [PATCH 30/54] fix: issue with google login --- common/apps/oauth2/views.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index 4c2111e..7c601aa 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -57,6 +57,8 @@ def get(self, request): class GoogleLoginTokenView(generics.CreateAPIView): serializer_class = CodeLoginSerializer + permission_classes = [AllowAny] + authentication_classes = [] def post(self, request): serializer = self.get_serializer(data=request.data) From 869001b32502951ea317ed9c8669535c27d371a5 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 18 Apr 2025 08:37:01 +0700 Subject: [PATCH 31/54] fix: optimize login with google --- common/apps/oauth2/views.py | 26 ++------------------------ common/utils/oauth2.py | 27 +++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 24 deletions(-) diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index 7c601aa..304faa0 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -11,7 +11,7 @@ from common.apps.oauth2.serializers import CodeLoginSerializer, OauthLoginSerializer from common.utils.encoder import decode_from_base64 -from common.utils.oauth2 import get_access_token, handle_access_token +from common.utils.oauth2 import get_access_token, handle_access_token, get_access_token_with_code class GoogleLoginView(generics.CreateAPIView): @@ -64,27 +64,5 @@ def post(self, request): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) authorization_code = serializer.validated_data["authorization_code"] - provider_settings = settings.SOCIALACCOUNT_PROVIDERS.get("google", {}).get( - "APP" - ) - - token_url = settings.OAUTH_CLIENTS["GOOGLE"]["TOKEN_URL"] - data = { - "code": authorization_code, - "client_id": provider_settings.get("client_id"), - "client_secret": provider_settings.get("secret"), - "redirect_uri": settings.OAUTH_CLIENTS["GOOGLE"]["CALLBACK_URL"], - "grant_type": "authorization_code", - } - - token_resp = requests.post(token_url, data=data, timeout=5) - if token_resp.status_code != 200: - return Response( - {"error": "Failed to get token", "detail": token_resp.json()}, - status=status.HTTP_400_BAD_REQUEST, - ) - - token_data = token_resp.json() - access_token = token_data.get("access_token") - + access_token = get_access_token_with_code(authorization_code, provider="GOOGLE") return handle_access_token(access_token=access_token, provider="GOOGLE") diff --git a/common/utils/oauth2.py b/common/utils/oauth2.py index 08941cb..0b99ff5 100644 --- a/common/utils/oauth2.py +++ b/common/utils/oauth2.py @@ -40,6 +40,33 @@ def get_access_token( return response.json()["access_token"] +def get_access_token_with_code( + authorization_code: str, provider: Literal["GOOGLE"] +) -> str: + provider_settings = settings.SOCIALACCOUNT_PROVIDERS.get(provider.lower(), {}).get( + "APP" + ) + print("provider_settings", settings.SOCIALACCOUNT_PROVIDERS, provider.lower()) + + token_url = settings.OAUTH_CLIENTS[provider]["TOKEN_URL"] + data = { + "code": authorization_code, + "client_id": provider_settings.get("client_id"), + "client_secret": provider_settings.get("secret"), + "redirect_uri": settings.OAUTH_CLIENTS[provider]["CALLBACK_URL"], + "grant_type": "authorization_code", + } + + token_resp = requests.post(token_url, data=data, timeout=5) + if token_resp.status_code != 200: + return Response( + {"error": "Failed to get token", "detail": token_resp.json()}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return token_resp.json().get("access_token") + + def handle_access_token(access_token, provider: Literal["GOOGLE"]): info_url = settings.OAUTH_CLIENTS[provider]["INFO_URL"] From aabec0212d56ffe1751c767656d6a39a38020730 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 21 Apr 2025 15:13:36 +0700 Subject: [PATCH 32/54] fix: send email time out --- common/utils/send_email.py | 41 +++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/common/utils/send_email.py b/common/utils/send_email.py index db33f99..b809652 100644 --- a/common/utils/send_email.py +++ b/common/utils/send_email.py @@ -1,25 +1,38 @@ -import smtplib - -from django.core.mail import send_mail +import boto3 +from botocore.exceptions import BotoCoreError, ClientError +from django.conf import settings from rest_framework.exceptions import ValidationError +client = boto3.client("ses", region_name=settings.AWS_S3.get("AWS_REGION")) + + +def send_email(sender, user_emails, subject, html_message): + """Send email via Amazon SES API using boto3.""" + + if isinstance(user_emails, str): + user_emails = [user_emails] -def send_email(sender, user_email, subject, message): - """Send email to user and store it in Redis.""" try: - send_mail( - subject=subject, - message="", - from_email=sender, - recipient_list=user_email, - html_message=message, - fail_silently=False, + response = client.send_email( + Source=sender, + Destination={"ToAddresses": user_emails}, + Message={ + "Subject": {"Data": subject, "Charset": "UTF-8"}, + "Body": { + "Html": {"Data": html_message, "Charset": "UTF-8"}, + "Text": { + "Data": "This email requires an HTML-compatible client.", + "Charset": "UTF-8", + }, + }, + }, ) + return response - except smtplib.SMTPDataError: + except client.exceptions.MessageRejected: raise ValidationError({"error": "Email address is not verified."}) - except smtplib.SMTPException as e: + except (BotoCoreError, ClientError) as e: raise ValidationError({"error": str(e)}) except Exception as e: From e4c422653861770b8623c443a3f5a65ae3375d3b Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 21 Apr 2025 16:04:25 +0700 Subject: [PATCH 33/54] fix: issue ci --- common/apps/oauth2/views.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index 304faa0..c33ffd0 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -1,8 +1,6 @@ import logging from operator import itemgetter -import requests -from django.conf import settings from django.shortcuts import redirect from rest_framework import generics, status from rest_framework.exceptions import ParseError @@ -11,7 +9,11 @@ from common.apps.oauth2.serializers import CodeLoginSerializer, OauthLoginSerializer from common.utils.encoder import decode_from_base64 -from common.utils.oauth2 import get_access_token, handle_access_token, get_access_token_with_code +from common.utils.oauth2 import ( + get_access_token, + get_access_token_with_code, + handle_access_token, +) class GoogleLoginView(generics.CreateAPIView): From 72bfb11d0d458e79e4c56f5b7a14f0d05b6fd88a Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 22 Apr 2025 16:27:35 +0700 Subject: [PATCH 34/54] feature: create token with jwt --- common/utils/token_jwt.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 common/utils/token_jwt.py diff --git a/common/utils/token_jwt.py b/common/utils/token_jwt.py new file mode 100644 index 0000000..25dec77 --- /dev/null +++ b/common/utils/token_jwt.py @@ -0,0 +1,13 @@ +from datetime import timedelta + +from rest_framework_simplejwt.tokens import AccessToken + + +def generate_token(data, exp=15): + token = AccessToken() + token.set_exp(lifetime=timedelta(minutes=exp)) + + for key, value in data.items(): + token[key] = value + + return str(token) From 483bb87dffa04b87a324246dc1fe81fc5aedc43f Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 22 Apr 2025 21:43:17 +0700 Subject: [PATCH 35/54] feature: add default for space --- .../space/migrations/0002_space_is_default.py | 18 ++++++++++++++++++ common/apps/space/models.py | 1 + .../0003_spaceroleuser_is_default.py | 18 ++++++++++++++++++ common/apps/space_role/models.py | 1 + 4 files changed, 38 insertions(+) create mode 100644 common/apps/space/migrations/0002_space_is_default.py create mode 100644 common/apps/space_role/migrations/0003_spaceroleuser_is_default.py diff --git a/common/apps/space/migrations/0002_space_is_default.py b/common/apps/space/migrations/0002_space_is_default.py new file mode 100644 index 0000000..092c874 --- /dev/null +++ b/common/apps/space/migrations/0002_space_is_default.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2025-04-24 03:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("space", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="space", + name="is_default", + field=models.BooleanField(default=False), + ), + ] diff --git a/common/apps/space/models.py b/common/apps/space/models.py index 9a51b15..4281c54 100644 --- a/common/apps/space/models.py +++ b/common/apps/space/models.py @@ -10,6 +10,7 @@ class Space(BaseModel, SynchronousTenantModel): logo = models.CharField(max_length=256) slug_name = models.SlugField(max_length=64, unique=True) is_active = models.BooleanField(default=True) + is_default = models.BooleanField(default=False) total_devices = models.IntegerField(default=0, validators=[MinValueValidator(0)]) created_by = models.UUIDField() diff --git a/common/apps/space_role/migrations/0003_spaceroleuser_is_default.py b/common/apps/space_role/migrations/0003_spaceroleuser_is_default.py new file mode 100644 index 0000000..1adcdf7 --- /dev/null +++ b/common/apps/space_role/migrations/0003_spaceroleuser_is_default.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2025-04-24 03:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("space_role", "0002_create_default_policies"), + ] + + operations = [ + migrations.AddField( + model_name="spaceroleuser", + name="is_default", + field=models.BooleanField(default=False), + ), + ] diff --git a/common/apps/space_role/models.py b/common/apps/space_role/models.py index 00f50f1..560323a 100644 --- a/common/apps/space_role/models.py +++ b/common/apps/space_role/models.py @@ -36,3 +36,4 @@ class SpaceRoleUser(BaseModel, SynchronousTenantModel): organization_user = models.ForeignKey( User, related_name="space_role_user", on_delete=models.CASCADE ) + is_default = models.BooleanField(default=False) From 584e2d44b0039587b90d0afbca2bc670c8d3973f Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 19 Jun 2025 14:18:51 +0700 Subject: [PATCH 36/54] feature: delete organization --- common/apps/organization/handler.py | 14 ++++++++++ common/apps/organization/tasks.py | 19 +++++++++++++ common/celery/routing.py | 43 +++++++++++++++++++---------- 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/common/apps/organization/handler.py b/common/apps/organization/handler.py index 0ded40c..3812341 100644 --- a/common/apps/organization/handler.py +++ b/common/apps/organization/handler.py @@ -14,3 +14,17 @@ def __init__(self, organization, owner_email): @abstractmethod def handle(self): pass + + +class DeleteOrganizationHandlerBase: + """ + The base class for Delete Organization Handler + Use by set DELETE_ORGANIZATION_HANDLER in Django setting file + """ + + def __init__(self, organization): + self._organization = organization + + @abstractmethod + def handle(self): + pass diff --git a/common/apps/organization/tasks.py b/common/apps/organization/tasks.py index 6a9304c..2c13c78 100644 --- a/common/apps/organization/tasks.py +++ b/common/apps/organization/tasks.py @@ -17,6 +17,13 @@ def get_new_organization_handler(): return None +def get_delete_organization_handler(): + handler_path = getattr(settings, "DELETE_ORGANIZATION_HANDLER", None) + if handler_path is not None: + return import_string(handler_path) + return None + + @task(name="spacedf.tasks.new_organization", max_retries=3) @transaction.atomic def create_organization(id, name, slug_name, is_active, owner, created_at, updated_at): @@ -44,3 +51,15 @@ def create_organization(id, name, slug_name, is_active, owner, created_at, updat if NewOrganizationHandler is not None: NewOrganizationHandler(organization, owner).handle() + + +@task(name="spacedf.tasks.delete_organization", max_retries=3) +@transaction.atomic +def delete_organization(slug_name): + logger.info(f"delete_organization({slug_name})") + DeleteOrganizationHandler = get_delete_organization_handler() + + organization = Organization.objects.get(schema_name=slug_name) + if DeleteOrganizationHandler is not None: + DeleteOrganizationHandler(organization).handle() + organization.delete(force_drop=True) diff --git a/common/celery/routing.py b/common/celery/routing.py index 19a1bc6..f1dde6e 100644 --- a/common/celery/routing.py +++ b/common/celery/routing.py @@ -51,17 +51,32 @@ def setup_organization_task_routing(): if celery_app.conf.task_routes is None: celery_app.conf.task_routes = {} - celery_app.conf.task_queues = celery_app.conf.task_queues + ( - Queue( - f"{settings.SERVICE_NAME}_new_organization", - exchange=Exchange("new_organization", type="fanout"), - routing_key="new_organization", - queue_arguments={ - "x-single-active-consumer": True, - }, - ), - ) - celery_app.conf.task_routes["spacedf.tasks.new_organization"] = { - "queue": f"{settings.SERVICE_NAME}_new_organization", - "routing_key": "new_organization", - } + organization_queues = [ + { + "name": "new_organization", + "exchange": "new_organization", + "routing_key": "new_organization", + }, + { + "name": "delete_organization", + "exchange": "delete_organization", + "routing_key": "delete_organization", + }, + ] + + new_queues = [] + for queue_cfg in organization_queues: + queue_name = f"{settings.SERVICE_NAME}_{queue_cfg['name']}" + new_queues.append( + Queue( + queue_name, + exchange=Exchange(queue_cfg["exchange"], type="fanout"), + routing_key=queue_cfg["routing_key"], + queue_arguments={"x-single-active-consumer": True}, + ) + ) + celery_app.conf.task_routes[f"spacedf.tasks.{queue_cfg['name']}"] = { + "queue": queue_name, + "routing_key": queue_cfg["routing_key"], + } + celery_app.conf.task_queues = celery_app.conf.task_queues + tuple(new_queues) From 3fbc87dcef9ad2c49d6ff8ab675fe424ec3b8a48 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 11 Sep 2025 10:04:12 +0700 Subject: [PATCH 37/54] fix: login with google fail --- common/apps/oauth2/views.py | 15 +-------------- common/utils/oauth2.py | 1 - 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index c33ffd0..8b4f283 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -7,11 +7,10 @@ from rest_framework.permissions import AllowAny from rest_framework.response import Response -from common.apps.oauth2.serializers import CodeLoginSerializer, OauthLoginSerializer +from common.apps.oauth2.serializers import OauthLoginSerializer from common.utils.encoder import decode_from_base64 from common.utils.oauth2 import ( get_access_token, - get_access_token_with_code, handle_access_token, ) @@ -56,15 +55,3 @@ def get(self, request): fe_redirect_url = f"{callback_url}?code={code}&state={state}" return redirect(fe_redirect_url) - -class GoogleLoginTokenView(generics.CreateAPIView): - serializer_class = CodeLoginSerializer - permission_classes = [AllowAny] - authentication_classes = [] - - def post(self, request): - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - authorization_code = serializer.validated_data["authorization_code"] - access_token = get_access_token_with_code(authorization_code, provider="GOOGLE") - return handle_access_token(access_token=access_token, provider="GOOGLE") diff --git a/common/utils/oauth2.py b/common/utils/oauth2.py index 0b99ff5..b695ff5 100644 --- a/common/utils/oauth2.py +++ b/common/utils/oauth2.py @@ -46,7 +46,6 @@ def get_access_token_with_code( provider_settings = settings.SOCIALACCOUNT_PROVIDERS.get(provider.lower(), {}).get( "APP" ) - print("provider_settings", settings.SOCIALACCOUNT_PROVIDERS, provider.lower()) token_url = settings.OAUTH_CLIENTS[provider]["TOKEN_URL"] data = { From 423881b64075ff2dc98ffbe312b1ef00039d81e6 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 11 Sep 2025 10:10:56 +0700 Subject: [PATCH 38/54] fix: ci issue --- common/apps/oauth2/views.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index 8b4f283..46123f8 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -9,10 +9,7 @@ from common.apps.oauth2.serializers import OauthLoginSerializer from common.utils.encoder import decode_from_base64 -from common.utils.oauth2 import ( - get_access_token, - handle_access_token, -) +from common.utils.oauth2 import get_access_token, handle_access_token class GoogleLoginView(generics.CreateAPIView): @@ -39,7 +36,6 @@ def post(self, request, *args, **kwargs): class GoogleLoginCallbackView(generics.RetrieveAPIView): - def get(self, request): code = request.GET.get("code") state = request.GET.get("state") @@ -54,4 +50,3 @@ def get(self, request): return Response({"error": str(e)}, status=400) fe_redirect_url = f"{callback_url}?code={code}&state={state}" return redirect(fe_redirect_url) - From cabd26642013e7891ee8594303d89ff8d8513250 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Wed, 17 Sep 2025 16:16:26 +0700 Subject: [PATCH 39/54] fix: update org hander --- common/apps/organization/handler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/apps/organization/handler.py b/common/apps/organization/handler.py index 3812341..eb2be3e 100644 --- a/common/apps/organization/handler.py +++ b/common/apps/organization/handler.py @@ -7,9 +7,9 @@ class NewOrganizationHandlerBase: Use by set NEW_ORGANIZATION_HANDLER in Django setting file """ - def __init__(self, organization, owner_email): + def __init__(self, organization, owner): self._organization = organization - self._owner_email = owner_email + self._owner = owner @abstractmethod def handle(self): From ef538021321b2b01c1525404322ebfdfd3c0139e Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Wed, 24 Sep 2025 11:54:30 +0700 Subject: [PATCH 40/54] fix: update exception handler --- common/errors/exception_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/errors/exception_handler.py b/common/errors/exception_handler.py index ca3d583..f73781f 100644 --- a/common/errors/exception_handler.py +++ b/common/errors/exception_handler.py @@ -9,7 +9,7 @@ def custom_exception_handler(exc, context): # Now add the error code to the response. if response is not None: - if isinstance(exc, APIException): + if isinstance(exc, APIException) and isinstance(response.data, dict): response.data["code"] = exc.get_codes() return response From 79a9e60f2ca629273133ea0963606a6339f3e64a Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 25 Sep 2025 16:44:32 +0700 Subject: [PATCH 41/54] fix: remove the delete org handler --- common/apps/organization/handler.py | 13 ------------- common/apps/organization/tasks.py | 12 ------------ common/utils/custom_fields.py | 30 +++++++++++++++++++++++++++++ 3 files changed, 30 insertions(+), 25 deletions(-) create mode 100644 common/utils/custom_fields.py diff --git a/common/apps/organization/handler.py b/common/apps/organization/handler.py index eb2be3e..34cc94b 100644 --- a/common/apps/organization/handler.py +++ b/common/apps/organization/handler.py @@ -15,16 +15,3 @@ def __init__(self, organization, owner): def handle(self): pass - -class DeleteOrganizationHandlerBase: - """ - The base class for Delete Organization Handler - Use by set DELETE_ORGANIZATION_HANDLER in Django setting file - """ - - def __init__(self, organization): - self._organization = organization - - @abstractmethod - def handle(self): - pass diff --git a/common/apps/organization/tasks.py b/common/apps/organization/tasks.py index 2c13c78..b695092 100644 --- a/common/apps/organization/tasks.py +++ b/common/apps/organization/tasks.py @@ -51,15 +51,3 @@ def create_organization(id, name, slug_name, is_active, owner, created_at, updat if NewOrganizationHandler is not None: NewOrganizationHandler(organization, owner).handle() - - -@task(name="spacedf.tasks.delete_organization", max_retries=3) -@transaction.atomic -def delete_organization(slug_name): - logger.info(f"delete_organization({slug_name})") - DeleteOrganizationHandler = get_delete_organization_handler() - - organization = Organization.objects.get(schema_name=slug_name) - if DeleteOrganizationHandler is not None: - DeleteOrganizationHandler(organization).handle() - organization.delete(force_drop=True) diff --git a/common/utils/custom_fields.py b/common/utils/custom_fields.py new file mode 100644 index 0000000..55ca955 --- /dev/null +++ b/common/utils/custom_fields.py @@ -0,0 +1,30 @@ +import re +from rest_framework import serializers +from rest_framework.validators import UniqueValidator + + +class HexCharField(serializers.CharField): + def __init__(self, length, unique=False, **kwargs): + self.length = length + self.format = re.compile(rf"^[a-fA-F0-9]{{{length}}}$") + self.unique = unique + super().__init__(**kwargs) + + def bind(self, field_name, parent): + super().bind(field_name, parent) + if self.unique and hasattr(parent.Meta, "model"): + model = parent.Meta.model + self.validators.append( + UniqueValidator( + queryset=model.objects.all(), + message=f"Device with this {field_name} already exists.", + ) + ) + + def to_internal_value(self, data): + value = super().to_internal_value(data) + if value and not self.format.fullmatch(value): + raise serializers.ValidationError( + f"Value must be {self.length} hex characters" + ) + return value From a9fa2dcaeb777887460fe84c700c270295538315 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 26 Sep 2025 14:02:59 +0700 Subject: [PATCH 42/54] fix: ci issue --- common/apps/organization/handler.py | 1 - .../migrations/0002_organizationuser_avatar_and_more.py | 1 - common/apps/space/migrations/0002_space_is_default.py | 1 - .../apps/space_role/migrations/0003_spaceroleuser_is_default.py | 1 - common/utils/custom_fields.py | 1 + 5 files changed, 1 insertion(+), 4 deletions(-) diff --git a/common/apps/organization/handler.py b/common/apps/organization/handler.py index 34cc94b..3cbf2c7 100644 --- a/common/apps/organization/handler.py +++ b/common/apps/organization/handler.py @@ -14,4 +14,3 @@ def __init__(self, organization, owner): @abstractmethod def handle(self): pass - diff --git a/common/apps/organization_user/migrations/0002_organizationuser_avatar_and_more.py b/common/apps/organization_user/migrations/0002_organizationuser_avatar_and_more.py index 7f6b020..b0d3775 100644 --- a/common/apps/organization_user/migrations/0002_organizationuser_avatar_and_more.py +++ b/common/apps/organization_user/migrations/0002_organizationuser_avatar_and_more.py @@ -5,7 +5,6 @@ class Migration(migrations.Migration): - dependencies = [ ("organization_user", "0001_initial"), ] diff --git a/common/apps/space/migrations/0002_space_is_default.py b/common/apps/space/migrations/0002_space_is_default.py index 092c874..ec961ad 100644 --- a/common/apps/space/migrations/0002_space_is_default.py +++ b/common/apps/space/migrations/0002_space_is_default.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("space", "0001_initial"), ] diff --git a/common/apps/space_role/migrations/0003_spaceroleuser_is_default.py b/common/apps/space_role/migrations/0003_spaceroleuser_is_default.py index 1adcdf7..85ebade 100644 --- a/common/apps/space_role/migrations/0003_spaceroleuser_is_default.py +++ b/common/apps/space_role/migrations/0003_spaceroleuser_is_default.py @@ -4,7 +4,6 @@ class Migration(migrations.Migration): - dependencies = [ ("space_role", "0002_create_default_policies"), ] diff --git a/common/utils/custom_fields.py b/common/utils/custom_fields.py index 55ca955..a2767f1 100644 --- a/common/utils/custom_fields.py +++ b/common/utils/custom_fields.py @@ -1,4 +1,5 @@ import re + from rest_framework import serializers from rest_framework.validators import UniqueValidator From 250f5d69b8f9765175c8018298fe7bca1f145cc2 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Tue, 30 Sep 2025 14:08:39 +0700 Subject: [PATCH 43/54] fix: delete org fail --- common/apps/organization/tasks.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/common/apps/organization/tasks.py b/common/apps/organization/tasks.py index b695092..435c1cd 100644 --- a/common/apps/organization/tasks.py +++ b/common/apps/organization/tasks.py @@ -17,13 +17,6 @@ def get_new_organization_handler(): return None -def get_delete_organization_handler(): - handler_path = getattr(settings, "DELETE_ORGANIZATION_HANDLER", None) - if handler_path is not None: - return import_string(handler_path) - return None - - @task(name="spacedf.tasks.new_organization", max_retries=3) @transaction.atomic def create_organization(id, name, slug_name, is_active, owner, created_at, updated_at): @@ -51,3 +44,11 @@ def create_organization(id, name, slug_name, is_active, owner, created_at, updat if NewOrganizationHandler is not None: NewOrganizationHandler(organization, owner).handle() + + +@task(name="spacedf.tasks.delete_organization", max_retries=3) +@transaction.atomic +def delete_organization(slug_name): + logger.info(f"delete_organization({slug_name})") + organization = Organization.objects.get(schema_name=slug_name) + organization.delete(force_drop=True) From 80ff621d8d42a9c470b6267f1404abe4f9fe5d2b Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Thu, 9 Oct 2025 17:08:16 +0700 Subject: [PATCH 44/54] feature: add some fields for space model --- .../space/migrations/0003_space_description.py | 18 ++++++++++++++++++ common/apps/space/models.py | 1 + 2 files changed, 19 insertions(+) create mode 100644 common/apps/space/migrations/0003_space_description.py diff --git a/common/apps/space/migrations/0003_space_description.py b/common/apps/space/migrations/0003_space_description.py new file mode 100644 index 0000000..29f8f2e --- /dev/null +++ b/common/apps/space/migrations/0003_space_description.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2025-10-09 08:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('space', '0002_space_is_default'), + ] + + operations = [ + migrations.AddField( + model_name='space', + name='description', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/common/apps/space/models.py b/common/apps/space/models.py index 4281c54..3c9b1bf 100644 --- a/common/apps/space/models.py +++ b/common/apps/space/models.py @@ -12,6 +12,7 @@ class Space(BaseModel, SynchronousTenantModel): is_active = models.BooleanField(default=True) is_default = models.BooleanField(default=False) total_devices = models.IntegerField(default=0, validators=[MinValueValidator(0)]) + description = models.TextField(null=True, blank=True) created_by = models.UUIDField() class Meta: From fdcb3550ff427612bc81a830bffe374bc011cbb2 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 27 Oct 2025 13:42:38 +0700 Subject: [PATCH 45/54] fix: login error when canceling --- common/apps/oauth2/views.py | 7 ++++++- common/celery/constants.py | 3 +++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index 46123f8..8c2966b 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -39,8 +39,9 @@ class GoogleLoginCallbackView(generics.RetrieveAPIView): def get(self, request): code = request.GET.get("code") state = request.GET.get("state") + error = request.GET.get("error") - if not code or not state: + if not state: return Response({"error": "Missing code or state"}, status=400) try: @@ -48,5 +49,9 @@ def get(self, request): callback_url = state_data["callback_url"] except Exception as e: return Response({"error": str(e)}, status=400) + + if error == "access_denied": + return redirect(callback_url) + fe_redirect_url = f"{callback_url}?code={code}&state={state}" return redirect(fe_redirect_url) diff --git a/common/celery/constants.py b/common/celery/constants.py index 34a2c5d..add933c 100644 --- a/common/celery/constants.py +++ b/common/celery/constants.py @@ -1,3 +1,6 @@ AUTH_SERVICE = "auth_service" AUTH_SERVICE_OAUTH_CREDENTIALS_CREATION = f"{AUTH_SERVICE}.oauth_credentials_creation" AUTH_SERVICE_ADD_OR_REMOVE_DEVICE = f"{AUTH_SERVICE}.add_or_remove_device" + +CONSOLE_SERVICE = "console_service" +CONSOLE_SERVICE_ADD_OR_REMOVE_SPACE = f"{CONSOLE_SERVICE}.add_or_remove_space" \ No newline at end of file From 9b6f7892a47984d71df8dba8594948c3f3e9ad5b Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 27 Oct 2025 13:53:51 +0700 Subject: [PATCH 46/54] fix: ci issue --- common/apps/oauth2/views.py | 2 +- common/apps/space/migrations/0003_space_description.py | 7 +++---- common/celery/constants.py | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/common/apps/oauth2/views.py b/common/apps/oauth2/views.py index 8c2966b..ad8a909 100644 --- a/common/apps/oauth2/views.py +++ b/common/apps/oauth2/views.py @@ -52,6 +52,6 @@ def get(self, request): if error == "access_denied": return redirect(callback_url) - + fe_redirect_url = f"{callback_url}?code={code}&state={state}" return redirect(fe_redirect_url) diff --git a/common/apps/space/migrations/0003_space_description.py b/common/apps/space/migrations/0003_space_description.py index 29f8f2e..bd0f934 100644 --- a/common/apps/space/migrations/0003_space_description.py +++ b/common/apps/space/migrations/0003_space_description.py @@ -4,15 +4,14 @@ class Migration(migrations.Migration): - dependencies = [ - ('space', '0002_space_is_default'), + ("space", "0002_space_is_default"), ] operations = [ migrations.AddField( - model_name='space', - name='description', + model_name="space", + name="description", field=models.TextField(blank=True, null=True), ), ] diff --git a/common/celery/constants.py b/common/celery/constants.py index add933c..f4d6190 100644 --- a/common/celery/constants.py +++ b/common/celery/constants.py @@ -3,4 +3,4 @@ AUTH_SERVICE_ADD_OR_REMOVE_DEVICE = f"{AUTH_SERVICE}.add_or_remove_device" CONSOLE_SERVICE = "console_service" -CONSOLE_SERVICE_ADD_OR_REMOVE_SPACE = f"{CONSOLE_SERVICE}.add_or_remove_space" \ No newline at end of file +CONSOLE_SERVICE_ADD_OR_REMOVE_SPACE = f"{CONSOLE_SERVICE}.add_or_remove_space" From 660547439bf006956f831fd95f6e1dd2afedb375 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 7 Nov 2025 15:44:26 +0700 Subject: [PATCH 47/54] feat: switch tenant from request --- common/utils/switch_tenant.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 common/utils/switch_tenant.py diff --git a/common/utils/switch_tenant.py b/common/utils/switch_tenant.py new file mode 100644 index 0000000..0296e64 --- /dev/null +++ b/common/utils/switch_tenant.py @@ -0,0 +1,34 @@ +from django.core.exceptions import ObjectDoesNotExist +from django.db import connection +from django_tenants.utils import get_tenant_domain_model +from rest_framework import status +from rest_framework.exceptions import NotFound, ParseError +from rest_framework.response import Response + + +class UseTenantFromRequestMixin: + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + + org_param = request.query_params.get("organization", None) + if org_param is not None: + organization = org_param + if organization == "": + return Response({"result": "deny"}, status=status.HTTP_200_OK) + else: + organization = request.headers.get("X-Organization") + + if not organization: + raise ParseError("Missing 'organization' parameter") + + domain_model = get_tenant_domain_model() + try: + domain = domain_model.objects.select_related("tenant").get( + tenant__schema_name=organization + ) + tenant = domain.tenant + except ObjectDoesNotExist: + raise NotFound(f"Tenant '{organization}' not found") + + connection.set_tenant(tenant) + request.tenant = tenant From 562904c1fdf3a3aa5287fce0887718cb08be8a8b Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 17 Nov 2025 14:33:18 +0700 Subject: [PATCH 48/54] fix: change account aws for ses --- common/utils/send_email.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/common/utils/send_email.py b/common/utils/send_email.py index b809652..11bd1e1 100644 --- a/common/utils/send_email.py +++ b/common/utils/send_email.py @@ -3,7 +3,12 @@ from django.conf import settings from rest_framework.exceptions import ValidationError -client = boto3.client("ses", region_name=settings.AWS_S3.get("AWS_REGION")) +client = boto3.client( + "ses", + region_name=settings.AWS_S3.get("AWS_REGION"), + aws_access_key_id=settings.EMAIL_HOST_USER, + aws_secret_access_key=settings.EMAIL_HOST_PASSWORD, +) def send_email(sender, user_emails, subject, html_message): From 93affb0d8b20868ed75d5500d4f32539e6748cf9 Mon Sep 17 00:00:00 2001 From: worty76 Date: Fri, 21 Nov 2025 17:59:05 +0700 Subject: [PATCH 49/54] refactor: move emqx business logic to common folder --- common/emqx/__init__.py | 5 + common/emqx/client.py | 260 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+) create mode 100644 common/emqx/__init__.py create mode 100644 common/emqx/client.py diff --git a/common/emqx/__init__.py b/common/emqx/__init__.py new file mode 100644 index 0000000..4eeed0a --- /dev/null +++ b/common/emqx/__init__.py @@ -0,0 +1,5 @@ +"""Shared EMQX utilities.""" + +from .client import EMQXClient + +__all__ = ["EMQXClient"] diff --git a/common/emqx/client.py b/common/emqx/client.py new file mode 100644 index 0000000..8d190a5 --- /dev/null +++ b/common/emqx/client.py @@ -0,0 +1,260 @@ +""" +Utility for managing EMQX connectors, actions, and rules via the REST API. + +Each RabbitMQ vhost gets its own MQTT connector, action, and rule so that tenant +traffic is isolated. Connector usernames follow the "vhost:user" convention +expected by the RabbitMQ MQTT plugin. +""" + +import logging +from typing import Iterable, Sequence +from urllib.parse import quote + +import requests +from django.conf import settings + +logger = logging.getLogger(__name__) + + +class EMQXClient: + def __init__(self) -> None: + self.session = requests.Session() + self.session.auth = ( + settings.EMQX_API_APP_ID, + settings.EMQX_API_APP_SECRET, + ) + self.base_url = settings.EMQX_API_URL.rstrip("/") + self.rule_prefix = getattr(settings, "EMQX_RULE_ID", "rabbitmq_device_messages") + self.default_rule_sql = getattr( + settings, + "EMQX_RULE_SQL", + 'SELECT * FROM "tenant/+/device/data"', + ) + + def _log_and_raise(self, resp: requests.Response) -> None: + try: + payload = resp.json() + except Exception: # noqa: BLE001 + payload = resp.text + logger.error( + "EMQX API call failed (%s %s): %s", + resp.request.method, + resp.request.url, + payload, + ) + resp.raise_for_status() + + @staticmethod + def _sanitize(name: str) -> str: + return "".join(char if char.isalnum() or char == "_" else "_" for char in name) + + def _action_name(self, vhost: str) -> str: + return f"device_messages_{self._sanitize(vhost)}" + + def _rule_id_for_vhost(self, vhost: str) -> str: + return f"{self.rule_prefix}_{self._sanitize(vhost)}" + + def _build_rule_sql(self, slugs: Sequence[str]) -> str: + unique_slugs = sorted({slug for slug in slugs if slug}) + if not unique_slugs: + raise ValueError("At least one slug required to build vhost rule SQL") + + base_sql = self.default_rule_sql.strip() + slug_list = ", ".join(f"'{slug}'" for slug in unique_slugs) + slug_clause = f"topic(2) IN ({slug_list})" + + if " where " in base_sql.lower(): + return f"{base_sql} AND ({slug_clause})" + + return f"{base_sql} WHERE {slug_clause}" + + @staticmethod + def _is_duplicate_action(resp: requests.Response) -> bool: + if resp.status_code not in (400, 409): + return False + try: + payload = resp.json() + except Exception: # noqa: BLE001 + return False + code = payload.get("code") + message = payload.get("message", "") + return code == "ALREADY_EXISTS" or ( + isinstance(message, str) and "already exists" in message.lower() + ) + + def _connector_id(self, connector_name: str) -> str: + return f"mqtt:{connector_name}" # noqa: E231 + + def _action_id(self, action_name: str) -> str: + return f"mqtt:{action_name}" # noqa: E231 + + def connector_name(self, vhost: str) -> str: + return f"mqtt_{self._sanitize(vhost)}" # noqa: E231 + + @staticmethod + def _is_duplicate_connector(resp: requests.Response) -> bool: + if resp.status_code not in (400, 409): + return False + try: + payload = resp.json() + except Exception: # noqa: BLE001 + return False + code = payload.get("code") + message = payload.get("message", "") + return code == "ALREADY_EXISTS" or ( + isinstance(message, str) and "already exists" in message.lower() + ) + + def ensure_connector( + self, + vhost: str, + rabbit_user: str, + rabbit_pass: str, + pool_size: int = 1, + ) -> str: + connector_name = self.connector_name(vhost) + payload = { + "type": "mqtt", + "name": connector_name, + "enable": True, + "server": f"{settings.RABBITMQ_HOST}:{settings.RABBITMQ_MQTT_PORT}", # noqa: E231 + "username": f"{vhost}:{rabbit_user}", # noqa: E231 + "password": rabbit_pass, + "pool_size": pool_size, + } + + resp = self.session.post(f"{self.base_url}/connectors", json=payload) + if resp.status_code in (200, 201): + logger.info("Created EMQX connector %s for vhost %s", connector_name, vhost) + return connector_name + if resp.status_code == 409 or self._is_duplicate_connector(resp): + update = self.session.put( + f"{self.base_url}/connectors/mqtt:{connector_name}", # noqa: E231 + json={ + "server": payload["server"], + "username": payload["username"], + "password": payload["password"], + "pool_size": pool_size, + }, + ) + if update.status_code >= 400: + self._log_and_raise(update) + return connector_name + + self._log_and_raise(resp) + return connector_name + + def ensure_vhost_action( + self, + vhost: str, + connector_name: str, + topic: str = "${topic}", + ) -> str: + action_name = self._action_name(vhost) + payload = { + "type": "mqtt", + "name": action_name, + "enable": True, + "connector": connector_name, + "parameters": {"topic": topic, "qos": 1, "retain": False}, + } + + resp = self.session.post(f"{self.base_url}/actions", json=payload) + if resp.status_code in (200, 201): + logger.info( + "Created EMQX action %s for connector %s", action_name, connector_name + ) + return action_name + if resp.status_code == 409 or self._is_duplicate_action(resp): + update = self.session.put( + f"{self.base_url}/actions/mqtt:{action_name}", # noqa: E231 + json={ + "connector": connector_name, + "parameters": {"topic": topic, "qos": 1, "retain": False}, + "enable": True, + }, + ) + if update.status_code >= 400: + self._log_and_raise(update) + return action_name + + self._log_and_raise(resp) + return action_name + + def ensure_vhost_rule(self, vhost: str, slugs: Iterable[str]) -> None: + slug_list = list(slugs) + if not slug_list: + raise ValueError("At least one slug required for rule creation") + + rule_id = self._rule_id_for_vhost(vhost) + sql = self._build_rule_sql(slug_list) + action_id = self._action_id(self._action_name(vhost)) + rule_url = f"{self.base_url}/rules/{rule_id}" + payload = { + "sql": sql, + "actions": [action_id], + "enable": True, + } + + resp = self.session.get(rule_url) + if resp.status_code == 200: + update = self.session.put(rule_url, json=payload) + if update.status_code >= 400: + self._log_and_raise(update) + logger.info("Updated EMQX rule %s for vhost %s", rule_id, vhost) + return + if resp.status_code == 404: + create_payload = { + "id": rule_id, + "name": rule_id, + "description": f"Forward tenant MQTT traffic for vhost {vhost}", + "sql": sql, + "actions": [action_id], + "enable": True, + } + create = self.session.post(f"{self.base_url}/rules", json=create_payload) + if create.status_code >= 400: + self._log_and_raise(create) + logger.info("Created EMQX rule %s for vhost %s", rule_id, vhost) + return + + self._log_and_raise(resp) + + def delete_vhost_rule(self, vhost: str) -> None: + rule_id = self._rule_id_for_vhost(vhost) + resp = self.session.delete(f"{self.base_url}/rules/{rule_id}") + if resp.status_code in (200, 204, 404): + logger.info("Deleted EMQX rule %s for vhost %s", rule_id, vhost) + return + self._log_and_raise(resp) + + def delete_connector(self, vhost: str) -> None: + connector_id = self._connector_id(self.connector_name(vhost)) + resp = self.session.delete(f"{self.base_url}/connectors/{connector_id}") + if resp.status_code in (200, 204, 404): + return + self._log_and_raise(resp) + + def delete_action(self, vhost: str) -> None: + action_id = self._action_id(self._action_name(vhost)) + resp = self.session.delete(f"{self.base_url}/actions/{action_id}") + if resp.status_code in (200, 204, 404): + return + self._log_and_raise(resp) + + def teardown_tenant(self, vhost: str, remaining_slugs: Iterable[str]) -> None: + slugs = sorted({slug for slug in remaining_slugs if slug}) + if slugs: + self.ensure_vhost_rule(vhost, slugs) + return + + self.delete_vhost_rule(vhost) + self.delete_action(vhost) + self.delete_connector(vhost) + + def disconnect_client(self, client_id: str) -> None: + client_path = quote(client_id, safe="") + resp = self.session.delete(f"{self.base_url}/clients/{client_path}") + if resp.status_code in (200, 202, 204, 404): + return + self._log_and_raise(resp) From b6c4fe1d54337ed438f07d672e646c8bbcf7d787 Mon Sep 17 00:00:00 2001 From: worty76 Date: Tue, 25 Nov 2025 10:14:47 +0700 Subject: [PATCH 50/54] chore: lowercase deveui --- common/utils/custom_fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/utils/custom_fields.py b/common/utils/custom_fields.py index a2767f1..645e5ea 100644 --- a/common/utils/custom_fields.py +++ b/common/utils/custom_fields.py @@ -28,4 +28,4 @@ def to_internal_value(self, data): raise serializers.ValidationError( f"Value must be {self.length} hex characters" ) - return value + return value.lower() From 92b499d463c82c517b7dfb6251ffe1b04b6ce37d Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Sat, 13 Dec 2025 10:53:04 +0700 Subject: [PATCH 51/54] feat: implement telemetry client --- common/utils/telemetry_client.py | 286 +++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 common/utils/telemetry_client.py diff --git a/common/utils/telemetry_client.py b/common/utils/telemetry_client.py new file mode 100644 index 0000000..6ac97e5 --- /dev/null +++ b/common/utils/telemetry_client.py @@ -0,0 +1,286 @@ +import logging +from dataclasses import dataclass +from datetime import datetime + +import requests +from django.conf import settings +from django.utils import timezone +from requests.exceptions import RequestException, Timeout + +logger = logging.getLogger(__name__) + + +def _parse_timestamp(timestamp: str) -> datetime: + """ + Parse timestamp from various formats + """ + if isinstance(timestamp, datetime): + return timestamp + + if isinstance(timestamp, str): + # Try ISO format first + try: + dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + if dt.tzinfo is None: + dt = timezone.make_aware(dt) + return dt + except ValueError: + pass + + # Try other common formats + formats = ["%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S.%f"] + for fmt in formats: + try: + dt = datetime.strptime(timestamp, fmt) + return timezone.make_aware(dt) + except ValueError: + continue + + raise ValueError(f"Unable to parse timestamp: {timestamp}") + + +@dataclass +class LocationPoint: + """Data class for a single location point""" + + timestamp: datetime + latitude: float + longitude: float + device_id: str + + +class TelemetryServiceClient: + """Client for interacting with the Telemetry Service API""" + + def __init__(self, base_url: str | None = None): + """ + Initialize the telemetry service client + """ + self.base_url = base_url or getattr( + settings, "TELEMETRY_SERVICE_URL", "http://telemetry:8080" + ) + self.timeout = 30 + + def get_location_history( + self, + device_id: str, + organization_slug: str, + space_slug: str, + start: datetime, + end: datetime | None = None, + limit: int = 10000, + ) -> list[LocationPoint]: + """ + Fetch location history for a device from the telemetry service + + Args: + device_id: The device ID to fetch data for + space_slug: The space slug + start: Start timestamp (optional) + end: End timestamp (optional) + limit: Maximum number of records to fetch + + Returns: + List of location data points sorted by timestamp + + Raises: + RequestException: If the API call fails + """ + endpoint = f"{self.base_url}/telemetry/v1/location/history" + params = {"device_id": device_id, "space_slug": space_slug, "limit": limit} + + if start: + params["start"] = ( + start.isoformat() if isinstance(start, datetime) else start + ) + + if end: + params["end"] = end.isoformat() if isinstance(end, datetime) else end + + try: + logger.info("Device ID: %s", device_id) + logger.info(f"Start: {start}") + logger.info(f"End: {end}") + logger.info(f"Limit: {limit}") + logger.info(f"Endpoint: {endpoint}") + logger.info(f"Request params: {params}") + + response = requests.get( + endpoint, + params=params, + timeout=self.timeout, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Organization": organization_slug, + }, + ) + + logger.info( + f"Response status code: {response.status_code}, {organization_slug}" + ) + + if response.status_code == 404: + logger.warning(f"404 - No location data found for device {device_id}") + return [] + + response.raise_for_status() + + data = response.json() + locations = data.get("locations", []) + logger.info(f"Received {len(locations)} locations") + + formatted_locations: list[LocationPoint] = [] + for loc in locations: + formatted_locations.append( + LocationPoint( + timestamp=_parse_timestamp(loc.get("timestamp", "")), + latitude=loc.get("latitude", 0), + longitude=loc.get("longitude", 0), + device_id=device_id, + ) + ) + + return formatted_locations + except Timeout: + logger.error( + f"Timeout while fetching location history for device {device_id}" + ) + raise + + except RequestException as e: + logger.error( + f"Error fetching location history for device {device_id}: {str(e)}" + ) + raise + + def get_last_location( + self, device_id: str, organization_slug: str, space_slug: str + ) -> LocationPoint | None: + """ + Fetch the most recent location for a device from the telemetry service + + Args: + device_id: The device ID to fetch data for + space_slug: The organization slug + + Returns: + The most recent location point, or None if not found + """ + endpoint = f"{self.base_url}/telemetry/v1/location/last" + + params = { + "device_id": device_id, + "space_slug": space_slug, + } + + try: + response = requests.get( + endpoint, + params=params, + timeout=self.timeout, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Organization": organization_slug, + }, + ) + + logger.info(f"Response status code: {response.status_code}") + + if response.status_code == 404: + logger.warning(f"No location data found for device {device_id}") + return None + + response.raise_for_status() + + data = response.json() + logger.info(f"Response: {data}") + + if "error" in data: + logger.warning(f"Error from telemetry service: {data['error']}") + return None + + location_point = LocationPoint( + timestamp=_parse_timestamp(data.get("timestamp", "")), + latitude=data.get("latitude", 0), + longitude=data.get("longitude", 0), + device_id=device_id, + ) + + return location_point + + except Timeout: + logger.error(f"Timeout while fetching last location for device {device_id}") + return None + + except RequestException as e: + logger.error( + f"Error fetching last location for device {device_id}: {str(e)}" + ) + return None + + def get_widget_data( + self, + entity_id: str, + display_type: str, + organization_slug: str, + start_time: str | None = None, + end_time: str | None = None, + group_by: str | None = None, + ) -> dict: + """ + Fetch widget data for a specific entity from the telemetry service + """ + endpoint = f"{self.base_url}/telemetry/v1/widget/data/{entity_id}" + params = {"display_type": display_type} + + if start_time: + params["start_time"] = start_time + + if end_time: + params["end_time"] = end_time + + if group_by: + params["group_by"] = group_by + + try: + response = requests.get( + endpoint, + params=params, + timeout=self.timeout, + headers={ + "Content-Type": "application/json", + "Accept": "application/json", + "X-Organization": organization_slug, + }, + ) + + logger.info(f"Widget data response status: {response.status_code}") + + if response.status_code == 404: + logger.warning(f"404 - No widget data found for entity {entity_id}") + return {} + + response.raise_for_status() + return response.json() + + except Timeout: + logger.error(f"Timeout while fetching widget data for entity {entity_id}") + raise + + except RequestException as e: + logger.error(f"Error fetching widget data for entity {entity_id}: {str(e)}") + raise + + def check_health(self) -> bool: + """ + Check if the telemetry service is healthy and reachable + """ + try: + endpoint = f"{self.base_url}/health" + response = requests.get(endpoint, timeout=5) + return response.status_code == 200 + except Exception as e: + logger.error(f"Telemetry service health check failed: {str(e)}") + return False From be64fe346776c0c86fe601b3ee2b250086366b4b Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Sat, 13 Dec 2025 19:05:59 +0700 Subject: [PATCH 52/54] feat: get device properties --- common/utils/telemetry_client.py | 108 +++++++++++++------------------ 1 file changed, 44 insertions(+), 64 deletions(-) diff --git a/common/utils/telemetry_client.py b/common/utils/telemetry_client.py index 6ac97e5..7ca6874 100644 --- a/common/utils/telemetry_client.py +++ b/common/utils/telemetry_client.py @@ -154,25 +154,29 @@ def get_location_history( ) raise - def get_last_location( - self, device_id: str, organization_slug: str, space_slug: str - ) -> LocationPoint | None: + def get_widget_data( + self, + entity_id: str, + display_type: str, + organization_slug: str, + start_time: str | None = None, + end_time: str | None = None, + group_by: str | None = None, + ) -> dict: + """ + Fetch widget data for a specific entity from the telemetry service """ - Fetch the most recent location for a device from the telemetry service + endpoint = f"{self.base_url}/telemetry/v1/widget/data/{entity_id}" + params = {"display_type": display_type} - Args: - device_id: The device ID to fetch data for - space_slug: The organization slug + if start_time: + params["start_time"] = start_time - Returns: - The most recent location point, or None if not found - """ - endpoint = f"{self.base_url}/telemetry/v1/location/last" + if end_time: + params["end_time"] = end_time - params = { - "device_id": device_id, - "space_slug": space_slug, - } + if group_by: + params["group_by"] = group_by try: response = requests.get( @@ -186,63 +190,39 @@ def get_last_location( }, ) - logger.info(f"Response status code: {response.status_code}") + logger.info(f"Widget data response status: {response.status_code}") if response.status_code == 404: - logger.warning(f"No location data found for device {device_id}") - return None + logger.warning(f"404 - No widget data found for entity {entity_id}") + return {} response.raise_for_status() - - data = response.json() - logger.info(f"Response: {data}") - - if "error" in data: - logger.warning(f"Error from telemetry service: {data['error']}") - return None - - location_point = LocationPoint( - timestamp=_parse_timestamp(data.get("timestamp", "")), - latitude=data.get("latitude", 0), - longitude=data.get("longitude", 0), - device_id=device_id, - ) - - return location_point + return response.json() except Timeout: - logger.error(f"Timeout while fetching last location for device {device_id}") - return None + logger.error(f"Timeout while fetching widget data for entity {entity_id}") + raise except RequestException as e: - logger.error( - f"Error fetching last location for device {device_id}: {str(e)}" - ) - return None + logger.error(f"Error fetching widget data for entity {entity_id}: {str(e)}") + raise - def get_widget_data( + def get_device_properties( self, - entity_id: str, - display_type: str, + device_id: str, organization_slug: str, - start_time: str | None = None, - end_time: str | None = None, - group_by: str | None = None, + space_slug: str, ) -> dict: """ - Fetch widget data for a specific entity from the telemetry service - """ - endpoint = f"{self.base_url}/telemetry/v1/widget/data/{entity_id}" - params = {"display_type": display_type} - - if start_time: - params["start_time"] = start_time + Fetch all device properties (all entities data) from telemetry service - if end_time: - params["end_time"] = end_time + """ + endpoint = f"{self.base_url}/telemetry/v1/data/latest" - if group_by: - params["group_by"] = group_by + params = { + "device_id": device_id, + "space_slug": space_slug, + } try: response = requests.get( @@ -256,21 +236,21 @@ def get_widget_data( }, ) - logger.info(f"Widget data response status: {response.status_code}") + logger.info(f"Device properties response status: {response.status_code}") if response.status_code == 404: - logger.warning(f"404 - No widget data found for entity {entity_id}") + logger.warning( + f"404 - No device properties found for device {device_id}" + ) return {} response.raise_for_status() return response.json() - except Timeout: - logger.error(f"Timeout while fetching widget data for entity {entity_id}") - raise - except RequestException as e: - logger.error(f"Error fetching widget data for entity {entity_id}: {str(e)}") + logger.error( + f"Error fetching device properties for device {device_id}: {str(e)}" + ) raise def check_health(self) -> bool: From 5c2d59af02427e04dc3dfce2e01226b84d4240e7 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Sun, 14 Dec 2025 12:52:00 +0700 Subject: [PATCH 53/54] fix: update api url --- common/utils/telemetry_client.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/utils/telemetry_client.py b/common/utils/telemetry_client.py index 7ca6874..62c3520 100644 --- a/common/utils/telemetry_client.py +++ b/common/utils/telemetry_client.py @@ -86,7 +86,7 @@ def get_location_history( Raises: RequestException: If the API call fails """ - endpoint = f"{self.base_url}/telemetry/v1/location/history" + endpoint = f"{self.base_url}/api/telemetry/v1/location/history" params = {"device_id": device_id, "space_slug": space_slug, "limit": limit} if start: @@ -166,7 +166,7 @@ def get_widget_data( """ Fetch widget data for a specific entity from the telemetry service """ - endpoint = f"{self.base_url}/telemetry/v1/widget/data/{entity_id}" + endpoint = f"{self.base_url}/api/telemetry/v1/widget/data/{entity_id}" params = {"display_type": display_type} if start_time: @@ -217,7 +217,7 @@ def get_device_properties( Fetch all device properties (all entities data) from telemetry service """ - endpoint = f"{self.base_url}/telemetry/v1/data/latest" + endpoint = f"{self.base_url}/api/telemetry/v1/data/latest" params = { "device_id": device_id, From 5cc161d7b7ab76a9c61ae660ec7684d05229c8b6 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Mon, 15 Dec 2025 00:55:06 +0700 Subject: [PATCH 54/54] fix: remove group_by field --- common/utils/telemetry_client.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/common/utils/telemetry_client.py b/common/utils/telemetry_client.py index 62c3520..052a3a1 100644 --- a/common/utils/telemetry_client.py +++ b/common/utils/telemetry_client.py @@ -161,7 +161,6 @@ def get_widget_data( organization_slug: str, start_time: str | None = None, end_time: str | None = None, - group_by: str | None = None, ) -> dict: """ Fetch widget data for a specific entity from the telemetry service @@ -175,9 +174,6 @@ def get_widget_data( if end_time: params["end_time"] = end_time - if group_by: - params["group_by"] = group_by - try: response = requests.get( endpoint,