From 56892f78cb1d034365bad6c07717c84863270102 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 16 Jan 2026 10:18:35 +0700 Subject: [PATCH 1/2] feat: implement user and org model --- .github/workflows/ci-dev.yml | 4 + .pre-commit-config.yaml | 5 + CLA.md | 67 ++++++ LICENSE | 202 ++++++++++++++++++ README.md | 62 ++++++ apps/authentication/__init__.py | 0 apps/authentication/apps.py | 6 + .../authentication/migrations/0001_initial.py | 134 ++++++++++++ apps/authentication/migrations/__init__.py | 0 apps/authentication/models.py | 63 ++++++ apps/authentication/serializers.py | 103 +++++++++ apps/authentication/services.py | 106 +++++++++ apps/authentication/urls.py | 27 +++ apps/authentication/views.py | 154 +++++++++++++ apps/organization/__init__.py | 0 apps/organization/apps.py | 6 + apps/organization/constants.py | 5 + apps/organization/migrations/0001_initial.py | 70 ++++++ apps/organization/migrations/__init__.py | 0 apps/organization/models.py | 34 +++ apps/organization/serializers.py | 23 ++ apps/organization/services.py | 44 ++++ apps/organization/tasks.py | 37 ++++ apps/organization/urls.py | 9 + apps/organization/views.py | 45 ++++ apps/organization_roles/__init__.py | 0 apps/organization_roles/apps.py | 9 + apps/organization_roles/constants.py | 32 +++ .../migrations/0001_initial.py | 182 ++++++++++++++++ .../organization_roles/migrations/__init__.py | 0 apps/organization_roles/models.py | 42 ++++ apps/organization_roles/services.py | 134 ++++++++++++ apps/organization_roles/signals.py | 17 ++ bootstrap_service/celery.py | 25 +++ .../management/commands/init_organization.py | 199 ++++++++++++++--- bootstrap_service/settings.py | 139 +++++++++++- bootstrap_service/urls.py | 58 +++++ bootstrap_service/wsgi.py | 16 ++ docker-entrypoint.sh | 7 + manage.py | 15 +- requirements.txt | 17 +- utils/views.py | 59 +++++ 42 files changed, 2119 insertions(+), 38 deletions(-) create mode 100644 CLA.md create mode 100644 LICENSE create mode 100644 apps/authentication/__init__.py create mode 100644 apps/authentication/apps.py create mode 100644 apps/authentication/migrations/0001_initial.py create mode 100644 apps/authentication/migrations/__init__.py create mode 100644 apps/authentication/models.py create mode 100644 apps/authentication/serializers.py create mode 100644 apps/authentication/services.py create mode 100644 apps/authentication/urls.py create mode 100644 apps/authentication/views.py create mode 100644 apps/organization/__init__.py create mode 100644 apps/organization/apps.py create mode 100644 apps/organization/constants.py create mode 100644 apps/organization/migrations/0001_initial.py create mode 100644 apps/organization/migrations/__init__.py create mode 100644 apps/organization/models.py create mode 100644 apps/organization/serializers.py create mode 100644 apps/organization/services.py create mode 100644 apps/organization/tasks.py create mode 100644 apps/organization/urls.py create mode 100644 apps/organization/views.py create mode 100644 apps/organization_roles/__init__.py create mode 100644 apps/organization_roles/apps.py create mode 100644 apps/organization_roles/constants.py create mode 100644 apps/organization_roles/migrations/0001_initial.py create mode 100644 apps/organization_roles/migrations/__init__.py create mode 100644 apps/organization_roles/models.py create mode 100644 apps/organization_roles/services.py create mode 100644 apps/organization_roles/signals.py create mode 100644 bootstrap_service/urls.py create mode 100644 bootstrap_service/wsgi.py create mode 100644 utils/views.py diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml index 1c0fd5a..3ac9ec3 100644 --- a/.github/workflows/ci-dev.yml +++ b/.github/workflows/ci-dev.yml @@ -20,9 +20,13 @@ jobs: python -m pip install --upgrade pip pip install flake8==4.0.1 pip install bandit==1.7.8 + pip install codespell==2.2.4 - name: Run Flake8 run: | flake8 - name: Run Bandit run: | bandit -r . + - name: Run Spell Check + run: | + codespell --skip="*.md,venv,migrations,*.pyc" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 32598d0..d6a891b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,3 +19,8 @@ repos: - id: bandit args: ["-r", ".", "-x", "**/venv/*"] pass_filenames: false + - repo: https://github.com/codespell-project/codespell + rev: v2.2.4 + hooks: + - id: codespell + args: ['--skip=*.md,venv,migrations,*.pyc'] diff --git a/CLA.md b/CLA.md new file mode 100644 index 0000000..093c6b7 --- /dev/null +++ b/CLA.md @@ -0,0 +1,67 @@ +# Contributor License Agreement (CLA) + +**Version 1.0** + +This Contributor License Agreement (“**Agreement**”) is entered into by **you** (“**Contributor**”) and **Digital Fortress** (“**Company**”) regarding your contributions to the **SpaceDF** project (“**Project**”). + +By submitting any Contribution to the Project, you agree to the following terms: + +## 1. Definitions + +- **“Contribution”** means any source code, documentation, design, or other material submitted by you to the Project. +- **“Submit”** means any form of electronic, written, or verbal communication intended to be included in the Project, including but not limited to pull requests, patches, issues, or comments. + +## 2. Copyright Ownership + +- You retain ownership of the copyright in your Contributions. +- Nothing in this Agreement transfers ownership of your intellectual property to the Company. + +## 3. License Grant + +You grant **Digital Fortress** a **perpetual, worldwide, non-exclusive, royalty-free, and irrevocable license** to: + +- Use +- Modify +- Distribute +- Re-license +- Sublicense +- Commercialize + +your Contributions as part of the Project or in any related products or services. + +This includes, but is not limited to, use in **proprietary**, **SaaS**, and **enterprise** offerings. + +## 4. Patent Grant + +You grant Digital Fortress a **perpetual, worldwide, royalty-free license** to any patent claims you own that are necessarily infringed by your Contributions. + +## 5. Representations + +You represent and warrant that: + +- You have the legal right to submit the Contributions. +- The Contributions do not violate or infringe upon any third-party rights. +- If your employer or organization has intellectual property policies, you have obtained all necessary permissions to make the Contributions. + +## 6. No Obligation + +The Company is **not obligated** to: + +- Accept your Contributions. +- Provide any form of compensation. +- Include your Contributions in any release or distribution. + +## 7. Public Attribution + +The Company **may**, but is not required to, publicly acknowledge or attribute your Contributions. + +## 8. License Compatibility + +- Your Contributions will be licensed to users under the Project’s open-source license (e.g., **Apache License 2.0**). +- This Agreement governs only the relationship between you and the Company and does not modify the Project’s open-source license. + +## 9. Governing Law + +This Agreement shall be governed by and construed in accordance with the laws of **Vietnam**. + +By submitting a Contribution, you confirm that you have read, understood, and agree to the terms of this Agreement. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..10398cd --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Digital Fortress + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index e69de29..dd66b57 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,62 @@ +# SpaceDF Bootstrap Service + +## Prerequisites +- Python 3.10 +- PostgreSQL + +## Clone source code + +``` +git clone -b dev git@github.com:Space-DF/bootstrap-service.git +git clone -b dev git@github.com:Space-DF/django-common-utils.git +``` + +## Setup + +- Install requirements + + ``` + pip install -r requirements.txt + ``` + +- Run RabbitMQ broker + ``` + docker run -d --name some-rabbit -p 5672:5672 -p 5673:5673 -p 15672:15672 rabbitmq:3-management + ``` + +- Run Redis + ``` + docker run -d --name redis -p 6379:6379 redis + ``` + +- Init .env + ``` + cp .env.example .env + ``` + +- Migrate + ``` + python manage.py migrate + ``` +## Run source code +- Run server + ``` + python manage.py runserver 8000 + ``` + +## Migration +When you make the change for the database model +- Make migration file + ``` + python manage.py makemigrations + ``` +- Migrate + ``` + python manage.py migrate_schemas + ``` + +## License +Licensed under the Apache License, Version 2.0 +See the LICENSE file for details. + +[![SpaceDF - A project from Digital Fortress](https://df.technology/images/SpaceDF.png)](https://df.technology/) \ No newline at end of file diff --git a/apps/authentication/__init__.py b/apps/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/apps.py b/apps/authentication/apps.py new file mode 100644 index 0000000..975015d --- /dev/null +++ b/apps/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.authentication" diff --git a/apps/authentication/migrations/0001_initial.py b/apps/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..3cf6033 --- /dev/null +++ b/apps/authentication/migrations/0001_initial.py @@ -0,0 +1,134 @@ +# Generated by Django 5.0.6 on 2026-01-15 06:58 + +import apps.authentication.models +import django.contrib.postgres.fields +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="RootUser", + fields=[ + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login" + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "first_name", + models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ( + "last_name", + models.CharField( + blank=True, max_length=150, verbose_name="last name" + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + 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 Provider"), + ("space_df", "Space Df"), + ], + max_length=256, + null=True, + ), + default=[""], + size=None, + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", apps.authentication.models.UserManager()), + ], + ), + ] diff --git a/apps/authentication/migrations/__init__.py b/apps/authentication/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/authentication/models.py b/apps/authentication/models.py new file mode 100644 index 0000000..ba05431 --- /dev/null +++ b/apps/authentication/models.py @@ -0,0 +1,63 @@ +import uuid + +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 _ + + +class UserManager(BaseUserManager): + """Define a model manager for User model with no username field.""" + + use_in_migrations = True + + def _create_user(self, email, password, **extra_fields): + """Create and save a User with the given email and password.""" + if not email: + raise ValueError("The given email must be set") + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_user(self, email, password=None, **extra_fields): + """Create and save a regular User with the given email and password.""" + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email, password, **extra_fields): + """Create and save a SuperUser with the given email and password.""" + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self._create_user(email, password, **extra_fields) + + def get_by_natural_key(self, username): + return self.get(**{"email__iexact": username}) + + +class RootUser(AbstractUser): + """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( + models.CharField(max_length=256, choices=SocialProvider.choices, null=True), + default=[SocialProvider.NONE_PROVIDER], + ) + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects = UserManager() diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py new file mode 100644 index 0000000..8fba35b --- /dev/null +++ b/apps/authentication/serializers.py @@ -0,0 +1,103 @@ +from common.apps.refresh_tokens.serializers import ( + BaseTokenObtainPairSerializer, + CustomTokenRefreshSerializer, + TokenPairSerializer, +) +from rest_framework import serializers + +from apps.authentication.models import RootUser +from apps.authentication.services import create_organization_jwt_tokens +from apps.organization.models import Organization + + +class UserSerializer(serializers.ModelSerializer): + email = serializers.EmailField(read_only=True) + + class Meta: + model = RootUser + fields = ("id", "first_name", "last_name", "email", "is_active") + + +class TokenObtainPairSerializer(BaseTokenObtainPairSerializer): + def get_tokens(self): + default_organization = Organization.objects.filter( + organizationpolicy__organizationrole__organization_role_user__root_user=self.user, + ).first() + default_organization_slug = ( + default_organization.slug_name if default_organization else None + ) + refresh_token, access_token = create_organization_jwt_tokens( + self.user, organization_slug=default_organization_slug + ) + + 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)} + + +class AuthTokenPairSerializer(TokenPairSerializer): + default_organization = serializers.CharField() + + +class OrganizationTokenRefreshSerializer(CustomTokenRefreshSerializer): + organization = serializers.CharField(write_only=True, allow_null=True) + + +class SendEmailSerializer(serializers.Serializer): + email = serializers.EmailField(required=True) + + +class ForgetPasswordSerializer(serializers.Serializer): + token = serializers.CharField() + password = serializers.CharField() + + +class ChangePasswordSerializer(serializers.Serializer): + password = serializers.CharField( + required=False, allow_blank=True, allow_null=True, write_only=True + ) + new_password = serializers.CharField(write_only=True) + + def validate_new_password(self, value: str): + if not any(char.isdigit() for char in value): + raise serializers.ValidationError( + "This new password must contain at least 1 digit." + ) + if all(char.isalnum() for char in value): + raise serializers.ValidationError( + "This new password must contain at least 1 special letter" + ) + if not any(char.isupper() for char in value): + raise serializers.ValidationError( + "This new password must contain at least 1 upper case letter" + ) + if not any(char.islower() for char in value): + raise serializers.ValidationError( + "This new password must contain at least 1 lower case letter" + ) + return value + + def update(self, instance, validated_data): + current_password = validated_data.get("password") + new_password = validated_data.get("new_password") + + if instance.has_usable_password(): + if not current_password: + raise serializers.ValidationError( + {"password": "Current password is required."} + ) + if not instance.check_password(current_password): + raise serializers.ValidationError( + {"error": "Current password is incorrect"} + ) + if current_password == new_password: + raise serializers.ValidationError( + {"error": "New password cannot be the same as the current password"} + ) + + instance.set_password(new_password) + instance.save() + return instance diff --git a/apps/authentication/services.py b/apps/authentication/services.py new file mode 100644 index 0000000..708ca08 --- /dev/null +++ b/apps/authentication/services.py @@ -0,0 +1,106 @@ +from operator import itemgetter +from typing import Literal + +import requests +from common.apps.refresh_tokens.services import create_jwt_tokens +from django.conf import settings +from django.core.cache import cache +from django.template.loader import render_to_string +from rest_framework import status +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response + +from apps.authentication.models import RootUser +from apps.organization.models import Organization +from apps.organization_roles.models import OrganizationRoleUser + + +def create_organization_access_token(user_id, access_token): + organization_roles_cache = cache.get(f"organization_roles_{user_id}") + if organization_roles_cache: + access_token["organization_roles"] = organization_roles_cache + return access_token + + # query role per organization + org_role_users = ( + OrganizationRoleUser.objects.filter( + root_user_id=user_id, organization_role__organization__is_active=True + ) + .select_related("organization_role__organization") + .order_by("organization_role__organization_id") + .distinct("organization_role__organization_id") + ) + + # build dict organization_slug -> role_name + organization_roles_dict = {} + for org_role_user in org_role_users: + org_slug = str(org_role_user.organization_role.organization.slug_name) + role_name = str(org_role_user.organization_role.name) + organization_roles_dict[org_slug] = role_name + + cache.set( + f"organization_roles_{user_id}", + organization_roles_dict, + timeout=60 * 60 * 24, + ) + + # update access token + access_token["organization_roles"] = organization_roles_dict + return access_token + + +def create_organization_jwt_tokens(user, organization_slug, issuer=None, **kwargs): + refresh_token, access_token = create_jwt_tokens(user, issuer, **kwargs) + + if organization_slug: + access_token = create_organization_access_token(user.id, access_token) + return refresh_token, access_token + + +def handle_access_token(access_token, provider: Literal["GOOGLE"]): + info_url = settings.OAUTH_CLIENTS[provider]["INFO_URL"] + + headers = {"Authorization": f"Bearer {access_token}"} + response = requests.post(url=info_url, headers=headers, timeout=10) + response.raise_for_status() + user_info_dict = response.json() + given_name, family_name, email = itemgetter("given_name", "family_name", "email")( + user_info_dict + ) + root_user, is_created = RootUser.objects.get_or_create( + email=email, + ) + if is_created: + root_user.first_name = given_name + root_user.last_name = family_name + root_user.save() + + default_organization = Organization.objects.filter( + organizationpolicy__organizationrole__organization_role_user__root_user=root_user, + ).first() + default_organization_slug = ( + default_organization.slug_name if default_organization else None + ) + + refresh, access = create_organization_jwt_tokens( + root_user, organization_slug=default_organization_slug + ) + return Response( + status=status.HTTP_200_OK, + data={ + "refresh": str(refresh), + "access": str(access), + "default_organization": default_organization_slug, + }, + ) + + +def render_email_format(template, data): + try: + html_message = render_to_string( + template, + data, + ) + return html_message + except Exception as e: + raise ValidationError({"error": f"Error: {e}"}) diff --git a/apps/authentication/urls.py b/apps/authentication/urls.py new file mode 100644 index 0000000..222152d --- /dev/null +++ b/apps/authentication/urls.py @@ -0,0 +1,27 @@ +from django.urls import path + +from apps.authentication.views import ( + ChangePasswordAPIView, + ForgetPasswordView, + LoginAPIView, + ProfileAPIView, + RefreshTokenView, + SendEmailToConfirmView, +) + +app_name = "auth" + +urlpatterns = [ + path("auth/login", LoginAPIView.as_view(), name="login"), + path( + "auth/change-password", ChangePasswordAPIView.as_view(), name="change_password" + ), + path("auth/refresh-token", RefreshTokenView.as_view(), name="refresh_token"), + path("auth/forget-password", ForgetPasswordView.as_view(), name="forget_password"), + path("user/me", ProfileAPIView.as_view(), name="profile"), + path( + "auth/send-email-confirm", + SendEmailToConfirmView.as_view(), + name="send_email_confirm", + ), +] diff --git a/apps/authentication/views.py b/apps/authentication/views.py new file mode 100644 index 0000000..c419c6c --- /dev/null +++ b/apps/authentication/views.py @@ -0,0 +1,154 @@ +from datetime import datetime, timezone + +from common.apps.refresh_tokens.serializers import TokenPairSerializer +from common.utils.send_email import send_email +from common.utils.token_jwt import generate_token +from django.conf import settings +from django.core.cache import cache +from django.shortcuts import get_object_or_404 +from drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status +from rest_framework.exceptions import NotFound +from rest_framework.permissions import AllowAny +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import AccessToken +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +from apps.authentication.models import RootUser +from apps.authentication.serializers import ( + ChangePasswordSerializer, + ForgetPasswordSerializer, + SendEmailSerializer, + UserSerializer, +) +from apps.authentication.services import ( + create_organization_access_token, + render_email_format, +) + + +class LoginAPIView(TokenObtainPairView): + authentication_classes = [] + + @swagger_auto_schema( + responses={status.HTTP_201_CREATED: TokenPairSerializer}, + ) + def post(self, request: Request, *args, **kwargs) -> Response: + return super().post(request, *args, **kwargs) + + +class RefreshTokenView(TokenRefreshView): + authentication_classes = [] + _serializer_class = "apps.authentication.serializers.CustomTokenRefreshSerializer" + + def get_serializer_context(self): + context = super().get_serializer_context() + return { + **context, + "access_token_handler": create_organization_access_token, + "access_token_handler_params": {}, + } + + +class SendEmailToConfirmView(generics.GenericAPIView): + serializer_class = SendEmailSerializer + permission_classes = [AllowAny] + authentication_classes = [] + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + email = serializer.validated_data["email"] + + if not RootUser.objects.filter(email=email).exists(): + return Response( + {"result": "No account found with this email address."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + subject = "🔒 Forgot your password? Reset now" + token = generate_token({"email": email}) + data = { + "redirect_url": f"{settings.HOST_FRONTEND_ADMIN}/auth/reset-password?token={token}", + "host": settings.HOST, + } + message = render_email_format("email_forget_password.html", data) + send_email(settings.DEFAULT_FROM_EMAIL, [email], subject, message) + return Response( + { + "result": "Please check your email to continue the password reset process" + }, + status=status.HTTP_200_OK, + ) + + +class ForgetPasswordView(generics.GenericAPIView): + serializer_class = ForgetPasswordSerializer + permission_classes = [AllowAny] + authentication_classes = [] + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + token_str = serializer.validated_data["token"] + if cache.get(f"used_token: {token_str}"): + return Response( + {"error": "This token has already been used"}, + status=status.HTTP_400_BAD_REQUEST, + ) + try: + token = AccessToken(token_str) + root_user = RootUser.objects.filter(email=token.get("email")).first() + if root_user: + root_user.set_password(serializer.validated_data["password"]) + root_user.save() + + exp_timestamp = token.get("exp") + exp_datetime = datetime.fromtimestamp(exp_timestamp, tz=timezone.utc) + ttl_seconds = int((exp_datetime - token.current_time).total_seconds()) + cache.set(f"used_token: {token_str}", True, timeout=ttl_seconds) + + return Response( + {"result": "The password changed successfully"}, + status=status.HTTP_200_OK, + ) + + return Response( + {"result": "The account for this email does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + return Response( + {"error": f"Invalid or expired token.{e}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class ChangePasswordAPIView(generics.GenericAPIView): + serializer_class = ChangePasswordSerializer + + def get_object(self): + user_id = self.request.headers.get("X-User-ID", None) + if not user_id: + return None + return get_object_or_404(RootUser, id=user_id) + + def put(self, request: Request): + instance = self.get_object() + serializer = self.get_serializer(instance, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response("helloworld", status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class ProfileAPIView(generics.RetrieveUpdateAPIView): + serializer_class = UserSerializer + queryset = RootUser.objects.all() + + def get_object(self): + user_id = self.request.headers.get("X-User-ID", None) + if user_id is None: + raise NotFound("The user not found") + return get_object_or_404(RootUser, id=user_id) diff --git a/apps/organization/__init__.py b/apps/organization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/organization/apps.py b/apps/organization/apps.py new file mode 100644 index 0000000..ff01435 --- /dev/null +++ b/apps/organization/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class OrganizationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.organization" diff --git a/apps/organization/constants.py b/apps/organization/constants.py new file mode 100644 index 0000000..d0dd2a1 --- /dev/null +++ b/apps/organization/constants.py @@ -0,0 +1,5 @@ +UNICODE_ASCII_CHARACTER_SET = ( + "abcdefghijklmnopqrstuvwxyz", + "ABCDEFGHIJKLMNOPQRSTUVWXYZ", + "0123456789", +) diff --git a/apps/organization/migrations/0001_initial.py b/apps/organization/migrations/0001_initial.py new file mode 100644 index 0000000..7184768 --- /dev/null +++ b/apps/organization/migrations/0001_initial.py @@ -0,0 +1,70 @@ +# Generated by Django 5.0.6 on 2026-01-15 06:58 + +import apps.organization.models +import django.core.validators +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Organization", + fields=[ + ( + "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=256)), + ("logo", models.CharField(max_length=256)), + ( + "slug_name", + models.SlugField( + max_length=64, + unique=True, + validators=[apps.organization.models.no_underscore_validator], + ), + ), + ("is_active", models.BooleanField(default=True)), + ( + "total_spaces", + models.IntegerField( + default=0, + validators=[django.core.validators.MinValueValidator(0)], + ), + ), + ( + "rabbitmq_vhost", + models.CharField( + blank=True, + help_text="Assigned RabbitMQ pooled vhost", + max_length=255, + null=True, + ), + ), + ( + "rabbitmq_provisioned_at", + models.DateTimeField( + blank=True, + help_text="When RabbitMQ resources were provisioned", + null=True, + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/apps/organization/migrations/__init__.py b/apps/organization/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/organization/models.py b/apps/organization/models.py new file mode 100644 index 0000000..8860cce --- /dev/null +++ b/apps/organization/models.py @@ -0,0 +1,34 @@ +import logging + +from common.models.base_model import BaseModel +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models + +logger = logging.getLogger(__name__) + + +def no_underscore_validator(value): + if "_" in value: + raise ValidationError("Slug cannot contain underscores (_).") + + +class Organization(BaseModel): + name = models.CharField(max_length=256) + logo = models.CharField(max_length=256) + slug_name = models.SlugField( + max_length=64, unique=True, validators=[no_underscore_validator] + ) + is_active = models.BooleanField(default=True) + total_spaces = models.IntegerField(default=0, validators=[MinValueValidator(0)]) + + # RabbitMQ provisioning fields + rabbitmq_vhost = models.CharField( + max_length=255, + blank=True, + null=True, + help_text="Assigned RabbitMQ pooled vhost", + ) + rabbitmq_provisioned_at = models.DateTimeField( + blank=True, null=True, help_text="When RabbitMQ resources were provisioned" + ) diff --git a/apps/organization/serializers.py b/apps/organization/serializers.py new file mode 100644 index 0000000..af235a1 --- /dev/null +++ b/apps/organization/serializers.py @@ -0,0 +1,23 @@ +from rest_framework import serializers + +from apps.organization.models import Organization + + +class OrganizationSerializer(serializers.ModelSerializer): + created_by = serializers.UUIDField(read_only=True) + total_member = serializers.IntegerField(read_only=True) + + class Meta: + model = Organization + fields = "__all__" + extra_kwargs = { + "id": {"read_only": True}, + "is_active": {"read_only": True}, + "created_at": {"read_only": True}, + "updated_at": {"read_only": True}, + } + + def validate_slug_name(self, data): + if "_" in data: + raise serializers.ValidationError(detail="slug name is invalid") + return data diff --git a/apps/organization/services.py b/apps/organization/services.py new file mode 100644 index 0000000..905fc7d --- /dev/null +++ b/apps/organization/services.py @@ -0,0 +1,44 @@ +from random import SystemRandom + +from django.conf import settings +from django.db.models import CharField, F, OuterRef, Value +from django.db.models.functions import Cast, Coalesce, Concat, NullIf, Trim + +from apps.organization.constants import UNICODE_ASCII_CHARACTER_SET +from apps.organization_roles.constants import OrganizationRoleType +from apps.organization_roles.models import OrganizationRoleUser + + +def get_owner_name_query_set(): + return ( + OrganizationRoleUser.objects.filter( + organization_role__organization_id=OuterRef("pk"), + organization_role__name__iexact=OrganizationRoleType.OWNER_ROLE, + ) + .order_by("id") + .annotate( + display=Coalesce( + NullIf( + Trim( + Concat( + Coalesce(F("root_user__first_name"), Value("")), + Value(" "), + Coalesce(F("root_user__last_name"), Value("")), + ) + ), + Value(""), + ), + Cast(F("root_user__email"), CharField()), + ) + ) + .values("display")[:1] + ) + + +def generate_client_secret(): + """ + Generate a suitable client secret + """ + length = settings.CLIENT_SECRET_GENERATOR_LENGTH + rand = SystemRandom() + return "".join(rand.choice(UNICODE_ASCII_CHARACTER_SET) for _ in range(length)) diff --git a/apps/organization/tasks.py b/apps/organization/tasks.py new file mode 100644 index 0000000..df69500 --- /dev/null +++ b/apps/organization/tasks.py @@ -0,0 +1,37 @@ +import logging +from operator import itemgetter + +from common.celery import constants +from common.celery.tasks import task +from django.db import transaction +from django.db.models import BooleanField, Case, F, IntegerField, Value, When +from django.db.models.functions import Greatest +from django.db.utils import ProgrammingError + +from apps.organization.models import Organization + +logger = logging.getLogger(__name__) + + +@task( + name=f"spacedf.tasks.{constants.CONSOLE_SERVICE_ADD_OR_REMOVE_SPACE}", + autoretry_for=(ProgrammingError,), + retry_backoff=2, + max_retries=3, +) +@transaction.atomic +def add_or_remove_space(**kwargs): + slug_name, action_type = itemgetter("slug_name", "type")(kwargs) + value = Case( + When(Value(action_type == "add", output_field=BooleanField()), then=Value(1)), + When( + Value(action_type == "remove", output_field=BooleanField()), + then=Value(-1), + ), + default=Value(0), + output_field=IntegerField(), + ) + + Organization.objects.filter(slug_name=slug_name).update( + total_spaces=Greatest(F("total_spaces") + value, Value(1)), + ) diff --git a/apps/organization/urls.py b/apps/organization/urls.py new file mode 100644 index 0000000..c3bd2f2 --- /dev/null +++ b/apps/organization/urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from apps.organization.views import OrganizationView + +app_name = "organization" + +urlpatterns = [ + path("organizations", OrganizationView.as_view(), name="organization"), +] diff --git a/apps/organization/views.py b/apps/organization/views.py new file mode 100644 index 0000000..4cf0788 --- /dev/null +++ b/apps/organization/views.py @@ -0,0 +1,45 @@ +from common.pagination.base_pagination import BasePagination +from django.db.models import CharField, Count, Subquery +from django.shortcuts import get_object_or_404 +from rest_framework.filters import OrderingFilter, SearchFilter + +from apps.organization.models import Organization +from apps.organization.serializers import OrganizationSerializer +from apps.organization.services import get_owner_name_query_set +from utils.views import OrganizationRetrieveAPIView + + +class OrganizationView(OrganizationRetrieveAPIView): + model = Organization + queryset = Organization.objects.all() + serializer_class = OrganizationSerializer + pagination_class = BasePagination + filter_backends = [OrderingFilter, SearchFilter] + ordering_fields = ["created_at"] + search_fields = ["name"] + + def get_object(self): + organization_slug = self.request.headers.get("X-Organization", None) + if not organization_slug: + return None + return get_object_or_404(Organization, slug_name=organization_slug) + + def get_queryset(self): + user_id = self.request.headers.get("X-User-ID", None) + if not user_id: + return self.queryset.none() + + return ( + Organization.objects.filter( + organization_role__organization_role_user__root_user_id=user_id + ) + .annotate( + created_by=Subquery( + get_owner_name_query_set(), output_field=CharField() + ), + total_member=Count( + "organization_role__organization_role_user", distinct=True + ), + ) + .distinct() + ) diff --git a/apps/organization_roles/__init__.py b/apps/organization_roles/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/organization_roles/apps.py b/apps/organization_roles/apps.py new file mode 100644 index 0000000..87b8301 --- /dev/null +++ b/apps/organization_roles/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class OrganizationRoleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.organization_roles" + + def ready(self): + from . import signals # noqa: F401 diff --git a/apps/organization_roles/constants.py b/apps/organization_roles/constants.py new file mode 100644 index 0000000..b79bc07 --- /dev/null +++ b/apps/organization_roles/constants.py @@ -0,0 +1,32 @@ +from django.db import models + + +class OrganizationRoleType(models.TextChoices): + OWNER_ROLE = "Owner" + ADMIN_ROLE = "Admin" + EDITOR_ROLE = "Editor" + VIEWER_ROLE = "Viewer" + + +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" + + # Organization Device + READ_ORGANIZATION_DEVICE = "READ_ORGANIZATION_DEVICE" + CREATE_ORGANIZATION_DEVICE = "CREATE_ORGANIZATION_DEVICE" + UPDATE_ORGANIZATION_DEVICE = "UPDATE_ORGANIZATION_DEVICE" + DELETE_ORGANIZATION_DEVICE = "DELETE_ORGANIZATION_DEVICE" diff --git a/apps/organization_roles/migrations/0001_initial.py b/apps/organization_roles/migrations/0001_initial.py new file mode 100644 index 0000000..f8cd03f --- /dev/null +++ b/apps/organization_roles/migrations/0001_initial.py @@ -0,0 +1,182 @@ +# Generated by Django 5.0.6 on 2026-01-15 06:58 + +import django.contrib.postgres.fields +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 = [ + ("organization", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="OrganizationPolicy", + fields=[ + ( + "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=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", + ), + ( + "READ_ORGANIZATION_DEVICE", + "Read Organization Device", + ), + ( + "CREATE_ORGANIZATION_DEVICE", + "Create Organization Device", + ), + ( + "UPDATE_ORGANIZATION_DEVICE", + "Update Organization Device", + ), + ( + "DELETE_ORGANIZATION_DEVICE", + "Delete Organization Device", + ), + ], + max_length=256, + ), + size=None, + ), + ), + ( + "organization", + models.ForeignKey( + default=None, + on_delete=django.db.models.deletion.CASCADE, + to="organization.organization", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="OrganizationRole", + fields=[ + ( + "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=256)), + ( + "organization", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_role", + to="organization.organization", + ), + ), + ( + "policies", + models.ManyToManyField(to="organization_roles.organizationpolicy"), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="OrganizationRoleUser", + fields=[ + ( + "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)), + ( + "organization_role", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="organization_role_user", + to="organization_roles.organizationrole", + ), + ), + ( + "root_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/apps/organization_roles/migrations/__init__.py b/apps/organization_roles/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/organization_roles/models.py b/apps/organization_roles/models.py new file mode 100644 index 0000000..d6359fe --- /dev/null +++ b/apps/organization_roles/models.py @@ -0,0 +1,42 @@ +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 + +from apps.authentication.models import RootUser +from apps.organization_roles.constants import OrganizationPermission + + +class OrganizationPolicy(BaseModel): + 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) + ) + organization = models.ForeignKey( + "organization.Organization", on_delete=models.CASCADE, default=None + ) + + +class OrganizationRole(BaseModel, SynchronousTenantModel): + name = models.CharField(max_length=256) + policies = models.ManyToManyField(OrganizationPolicy) + organization = models.ForeignKey( + "organization.Organization", + on_delete=models.CASCADE, + related_name="organization_role", + ) + + +class OrganizationRoleUser(BaseModel): + organization_role = models.ForeignKey( + OrganizationRole, + related_name="organization_role_user", + on_delete=models.CASCADE, + ) + root_user = models.ForeignKey( + RootUser, + related_name="organization_role_user", + on_delete=models.CASCADE, + ) diff --git a/apps/organization_roles/services.py b/apps/organization_roles/services.py new file mode 100644 index 0000000..de21d5e --- /dev/null +++ b/apps/organization_roles/services.py @@ -0,0 +1,134 @@ +from django.contrib.auth import get_user_model +from django.core.cache import cache + +from apps.organization_roles.constants import OrganizationPermission +from apps.organization_roles.models import OrganizationPolicy, OrganizationRole + +User = get_user_model() + + +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 edit-only access", + "description": "Provide edit only access to Organization's Role services", + "tags": ["organization-role", "edit-only"], + "permissions": [ + OrganizationPermission.UPDATE_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 edit-only access", + "description": "Provide edit only access to Organization's Member services", + "tags": ["organization-member", "edit-only"], + "permissions": [ + OrganizationPermission.UPDATE_ORGANIZATION_MEMBER_ROLE, + ], + }, + { + "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, + ], + }, + { + "name": "Organization's Device read-only access", + "description": "Provide read only access to Organization's Device services", + "tags": ["organization-device", "read-only"], + "permissions": [ + OrganizationPermission.READ_ORGANIZATION_DEVICE, + ], + }, + { + "name": "Organization's Device edit-only access", + "description": "Provide edit only access to Organization's Device services", + "tags": ["organization-device", "edit-only"], + "permissions": [ + OrganizationPermission.UPDATE_ORGANIZATION_DEVICE, + ], + }, + { + "name": "Organization's Device full access", + "description": "Grants full access to Organization's Device resources and access to related services", + "tags": ["organization-device", "full-access"], + "permissions": [ + OrganizationPermission.READ_ORGANIZATION_DEVICE, + OrganizationPermission.CREATE_ORGANIZATION_DEVICE, + OrganizationPermission.UPDATE_ORGANIZATION_DEVICE, + OrganizationPermission.DELETE_ORGANIZATION_DEVICE, + ], + }, +] + + +def create_default_policies(organization): + organization_policies = [] + for policy in default_policies: + organization_policy = OrganizationPolicy(**policy, organization=organization) + organization_policy.save() + organization_policies.append(organization_policy.pk) + return organization_policies + + +def create_default_organization_role_by_policy_tag(name, tag, organization): + policies = OrganizationPolicy.objects.filter( + tags__icontains=tag, organization=organization + ).all() + organization_role = OrganizationRole(name=name, organization=organization) + organization_role.save() + organization_role.policies.set([policy.pk for policy in policies]) + organization_role.save() + return organization_role + + +def clear_user_permission_cache(user_id): + if user_id: + cache_key = f"organization_roles_{user_id}" + if cache.get(cache_key): + cache.delete(cache_key) diff --git a/apps/organization_roles/signals.py b/apps/organization_roles/signals.py new file mode 100644 index 0000000..30fc3a3 --- /dev/null +++ b/apps/organization_roles/signals.py @@ -0,0 +1,17 @@ +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from apps.organization_roles.models import OrganizationRoleUser +from apps.organization_roles.services import clear_user_permission_cache + + +@receiver(post_save, sender=OrganizationRoleUser) +def handle_post_save(sender, instance, created, **kwargs): + user_id = getattr(instance, "root_user_id", None) + clear_user_permission_cache(user_id) + + +@receiver(post_delete, sender=OrganizationRoleUser) +def handle_post_delete(sender, instance, **kwargs): + user_id = getattr(instance, "root_user_id", None) + clear_user_permission_cache(user_id) diff --git a/bootstrap_service/celery.py b/bootstrap_service/celery.py index 150af41..0d511a1 100644 --- a/bootstrap_service/celery.py +++ b/bootstrap_service/celery.py @@ -6,7 +6,9 @@ sys.path.append(os.path.abspath(os.path.join("..", "django-common-utils"))) from celery import Celery +from common.celery import constants from dotenv import load_dotenv +from kombu import Exchange, Queue load_dotenv() @@ -14,3 +16,26 @@ app = Celery("bootstrap_service") app.config_from_object("django.conf:settings", namespace="CELERY") app.autodiscover_tasks() + +TASKS_CONSOLE = [ + constants.CONSOLE_SERVICE_ADD_OR_REMOVE_SPACE, +] + +existing = {queue.name: queue for queue in (app.conf.task_queues or ())} +routes = dict(app.conf.task_routes or {}) + +for name in TASKS_CONSOLE: + if name not in existing: + existing[name] = Queue( + name, + exchange=Exchange(name, type="direct"), + routing_key=f"spacedf.tasks.{name}", + durable=True, + ) + routes[f"spacedf.tasks.{name}"] = { + "queue": name, + "routing_key": f"spacedf.tasks.{name}", + } + +app.conf.task_queues = tuple(existing.values()) +app.conf.task_routes = routes diff --git a/bootstrap_service/management/commands/init_organization.py b/bootstrap_service/management/commands/init_organization.py index 18b3cf2..dc4537d 100644 --- a/bootstrap_service/management/commands/init_organization.py +++ b/bootstrap_service/management/commands/init_organization.py @@ -1,5 +1,18 @@ +# Copyright 2026 Digital Fortress. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + import os -import time import uuid from datetime import datetime @@ -11,6 +24,14 @@ from django.utils.module_loading import import_string from kombu import Exchange +from apps.authentication.models import RootUser +from apps.organization.models import Organization +from apps.organization_roles.constants import OrganizationRoleType +from apps.organization_roles.models import OrganizationRoleUser +from apps.organization_roles.services import ( + create_default_organization_role_by_policy_tag, + create_default_policies, +) from utils.check_tenant_exists import check_tenant_exists from utils.event_publisher import publish_org_event @@ -30,36 +51,34 @@ def add_arguments(self, parser): "--owner-password", type=str, help="Owner password", required=False ) - def handle(self, *args, **kwargs): # noqa: C901 - org_name = kwargs.get("org_name") or os.getenv("ORG_NAME") - org_slug = kwargs.get("org_slug") or os.getenv("ORG_SLUG") - owner_email = kwargs.get("owner_email") or os.getenv("OWNER_EMAIL") - owner_password = kwargs.get("owner_password") or os.getenv("OWNER_PASSWORD") - org_id = str(uuid.uuid4()) - - self.stdout.write( - self.style.SUCCESS( - f"Creating schema for organization: {org_name} ({org_slug})" - ) - ) + def _get_config(self, **kwargs): + """Extract configuration from arguments or environment variables.""" + return { + "org_name": kwargs.get("org_name") or os.getenv("ORG_NAME"), + "org_slug": kwargs.get("org_slug") or os.getenv("ORG_SLUG"), + "owner_email": kwargs.get("owner_email") or os.getenv("OWNER_EMAIL"), + "owner_password": kwargs.get("owner_password") + or os.getenv("OWNER_PASSWORD"), + } - # Check if tenant already exists - provisioner = RabbitMQProvisioner() + def _provision_rabbitmq(self, provisioner, org_id, org_slug): + """Provision or retrieve existing RabbitMQ resources.""" existing = check_tenant_exists(provisioner, org_slug) if existing: self.stdout.write( self.style.WARNING( - f"Organization '{org_slug}' already provisioned in vhost '{existing['vhost']}'. " + f"Organization '{org_slug}' already provisioned in vhost '{existing['vhost']}'" ) ) - result = existing - else: - self.stdout.write( - self.style.SUCCESS(f"Provisioning organization '{org_slug}'...") - ) - result = provisioner.provision_tenant(org_id, org_slug, 1112) + return existing - # Publish org.created event with minimal required data + self.stdout.write( + self.style.SUCCESS(f"Provisioning organization '{org_slug}'...") + ) + return provisioner.provision_tenant(org_id, org_slug, 1112) + + def _publish_org_event(self, org_id, org_slug, org_name, result): + """Publish organization created event.""" publish_org_event( "org.created", str(uuid.uuid4()), @@ -83,9 +102,42 @@ def handle(self, *args, **kwargs): # noqa: C901 }, ) - celery_app = import_string(settings.CELERY_APP) - encrypted_password = make_password(owner_password) + def _create_organization_with_roles(self, org_name, org_slug, result): + """Create organization with default policies and roles.""" + organization = Organization.objects.create( + name=org_name, + slug_name=org_slug, + logo="", + is_active=True, + rabbitmq_vhost=result.get("vhost", ""), + rabbitmq_provisioned_at=timezone.now(), + ) + self.stdout.write(self.style.SUCCESS(f"Created organization: {org_name}")) + + create_default_policies(organization) + self.stdout.write(self.style.SUCCESS("Created default policies")) + role_mappings = [ + (OrganizationRoleType.OWNER_ROLE, "administrator"), + (OrganizationRoleType.ADMIN_ROLE, "full-access"), + (OrganizationRoleType.VIEWER_ROLE, "read-only"), + (OrganizationRoleType.EDITOR_ROLE, "edit-only"), + ] + + owner_role = None + for role_type, policy_tag in role_mappings: + role = create_default_organization_role_by_policy_tag( + role_type, policy_tag, organization + ) + if role_type == OrganizationRoleType.OWNER_ROLE: + owner_role = role + + self.stdout.write(self.style.SUCCESS("Created default roles")) + return organization, owner_role + + def _send_celery_task(self, org_id, org_name, org_slug, user, encrypted_password): + """Send initialization task to Celery.""" + celery_app = import_string(settings.CELERY_APP) celery_app.send_task( name="spacedf.tasks.new_organization", exchange=Exchange("new_organization", type="fanout"), @@ -96,8 +148,8 @@ def handle(self, *args, **kwargs): # noqa: C901 "slug_name": org_slug, "is_active": True, "owner": { - "id": str(uuid.uuid4()), - "email": owner_email, + "id": str(user.id), + "email": user.email, "password": encrypted_password, }, "created_at": datetime.now().isoformat(), @@ -105,12 +157,95 @@ def handle(self, *args, **kwargs): # noqa: C901 }, ) + def _send_delete_celery_task(self, org_slug): + """Send organization deletion task to Celery.""" + celery_app = import_string(settings.CELERY_APP) + celery_app.send_task( + name="spacedf.tasks.delete_organization", + exchange=Exchange("delete_organization", type="fanout"), + routing_key="delete_organization", + kwargs={ + "slug_name": org_slug, + }, + ) + + def _delete_organization(self, provisioner, organization): + """Delete organization and cascade-related data from all services.""" + org_slug = organization.slug_name + org_id = organization.id + vhost_name = organization.rabbitmq_vhost + + publish_org_event( + "org.deleted", + str(uuid.uuid4()), + timezone.now().isoformat(), + { + "id": str(org_id), + "slug": org_slug, + "deleted_at": timezone.now().isoformat(), + }, + ) + + # Send Celery task for deletion + self._send_delete_celery_task(org_slug) + + # Delete RabbitMQ resources + provisioner.delete_tenant(vhost_name, org_slug) + organization.delete() + + self.stdout.write( + self.style.SUCCESS(f"Deleted organization '{org_slug}' (ID: {org_id})") + ) + + def handle(self, *args, **kwargs): + config = self._get_config(**kwargs) + org_name, org_slug = config["org_name"], config["org_slug"] + owner_email, owner_password = config["owner_email"], config["owner_password"] + encrypted_password = make_password(owner_password) + org_id = str(uuid.uuid4()) + provisioner = RabbitMQProvisioner() + + existing_org = Organization.objects.first() + if existing_org and existing_org.slug_name != org_slug: + self.stdout.write( + self.style.WARNING( + f"Organization slug changed from '{existing_org.slug_name}' to '{org_slug}'. " + "Deleting old organization..." + ) + ) + self._delete_organization(provisioner, existing_org) + existing_org = None + + if not existing_org: + result = self._provision_rabbitmq(provisioner, org_id, org_slug) + self.stdout.write( + self.style.SUCCESS(f"Creating organization: {org_name} ({org_slug})") + ) + # Create organization, roles, and assign owner + user, _ = RootUser.objects.get_or_create( + email=owner_email, defaults={"password": encrypted_password} + ) + self.stdout.write(self.style.SUCCESS(f"Created owner user: {owner_email}")) + _, owner_role = self._create_organization_with_roles( + org_name, org_slug, result + ) + OrganizationRoleUser(root_user=user, organization_role=owner_role).save() + self.stdout.write( + self.style.SUCCESS(f"Assigned owner role to {owner_email}") + ) + + # Publish organization event + self._publish_org_event(org_id, org_slug, org_name, result) + self._send_celery_task(org_id, org_name, org_slug, user, encrypted_password) + else: + self.stdout.write( + self.style.WARNING( + f"Organization '{org_slug}' already exists. Skipping org creation..." + ) + ) + self.stdout.write( self.style.SUCCESS( - f"Dispatched schema creation task for organization '{org_name}' with ID: {org_id}" + f"Organization '{org_name}' initialized with ID: {org_id}" ) ) - - self.stdout.write("Waiting for task processing...") - time.sleep(5) - self.stdout.write(self.style.SUCCESS("Task dispatch complete.")) diff --git a/bootstrap_service/settings.py b/bootstrap_service/settings.py index e34af0a..3e3540a 100644 --- a/bootstrap_service/settings.py +++ b/bootstrap_service/settings.py @@ -1,29 +1,149 @@ +# Copyright 2026 Digital Fortress. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """ Django settings for bootstrap_service project. """ import os +from datetime import timedelta from pathlib import Path +# Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +COMMON_UTILS_DIR = Path(__file__).resolve().parent.parent.parent / "django-common-utils" SECRET_KEY = os.getenv( "SECRET_KEY", "django-insecure-*$0b8ibx7uzk45cm+fxw7*jj(yzi2ye!l4+!dnyxa-u-nbuz=q" ) DJANGO_SETTINGS_MODULE = "bootstrap_service.settings" +ROOT_URLCONF = "bootstrap_service.urls" -DEBUG = False +DEBUG = True ALLOWED_HOSTS = ["*"] +HOST = os.getenv("HOST", "http://localhost:8000/") +HOST_FRONTEND_ADMIN = os.getenv("HOST_FRONTEND_ADMIN", "http://localhost:3000/") # noqa + # Timezone configuration USE_TZ = True TIME_ZONE = "UTC" # Minimal installed apps for management commands INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "drf_yasg", + "common.apps.refresh_tokens", "bootstrap_service", + "apps.organization", + "apps.organization_roles", + "apps.authentication", +] + +REFRESH_TOKEN_CLASS = "rest_framework_simplejwt.tokens.RefreshToken" # nosec B105 + +AWS_S3 = { + "AWS_ACCESS_KEY_ID": os.getenv("AWS_ACCESS_KEY_ID", ""), # noqa + "AWS_SECRET_ACCESS_KEY": os.getenv("AWS_SECRET_ACCESS_KEY", ""), # noqa + "AWS_STORAGE_BUCKET_NAME": os.getenv("AWS_STORAGE_BUCKET_NAME", ""), # noqa + "AWS_REGION": os.getenv("AWS_REGION", "us-east-1"), # noqa +} + + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", +] +# JWT config +JWT_PRIVATE_KEY = os.getenv("JWT_PRIVATE_KEY") +JWT_PUBLIC_KEY = os.getenv("JWT_PUBLIC_KEY") + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(hours=1), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": True, + "ALGORITHM": "RS256", + "SIGNING_KEY": JWT_PRIVATE_KEY, + "VERIFYING_KEY": JWT_PUBLIC_KEY, + "AUTH_HEADER_TYPES": ("Bearer",), + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "JTI_CLAIM": "jti", + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(days=7), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=10), + "TOKEN_REFRESH_SERIALIZER": "common.apps.refresh_tokens.serializers.CustomTokenRefreshSerializer", + "TOKEN_OBTAIN_SERIALIZER": "apps.authentication.serializers.TokenObtainPairSerializer", +} +REFRESH_TOKEN_CLASS = "rest_framework_simplejwt.tokens.RefreshToken" # nosec B105 + +# auth config +AUTH_USER_MODEL = "authentication.RootUser" +ACCOUNT_USER_MODEL_USERNAME_FIELD = None +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_USERNAME_REQUIRED = False +ACCOUNT_AUTHENTICATION_METHOD = "email" + +# Middleware configuration (required for admin application) +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +# Internationalization +# https://docs.djangoproject.com/en/3.2/topics/i18n/ +LANGUAGE_CODE = "en-us" +TIME_ZONE = "UTC" +USE_I18N = True +USE_L10N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/3.2/howto/static-files/ +STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles") +STATIC_URL = "static/" +STATICFILES_DIRS = (os.path.join(COMMON_UTILS_DIR, "common", "static"),) + +# Templates configuration (required for admin application) +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [COMMON_UTILS_DIR / "common" / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, ] RABBITMQ_URL = os.getenv("RABBITMQ_URL", "amqp://default:password@rabbitmq:5672/") @@ -69,3 +189,20 @@ "level": "INFO", }, } + +# Database +# https://docs.djangoproject.com/en/3.2/ref/settings/#databases +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql_psycopg2", + "NAME": os.getenv("DB_NAME", "spacedf_console_service"), + "USER": os.getenv("DB_USERNAME", "postgres"), + "PASSWORD": os.getenv("DB_PASSWORD", "postgres"), + "HOST": os.getenv("DB_HOST", "localhost"), + "PORT": os.getenv("DB_PORT", 25060), + } +} + +DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL", "") +EMAIL_HOST_USER = os.getenv("EMAIL_HOST_USER", "") +EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD", "") diff --git a/bootstrap_service/urls.py b/bootstrap_service/urls.py new file mode 100644 index 0000000..36fbf3b --- /dev/null +++ b/bootstrap_service/urls.py @@ -0,0 +1,58 @@ +"""bootstrap_service URL Configuration + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/3.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" + +from django.contrib import admin +from django.db import connection +from django.http import HttpResponse +from django.urls import include, path, re_path +from drf_yasg import openapi +from drf_yasg.views import get_schema_view +from rest_framework import permissions + +schema_view = get_schema_view( + openapi.Info( + title="SPACEDF CONSOLE API", + default_version="v1", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="contact@snippets.local"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=[permissions.AllowAny], +) + + +def health_check(_): + if not connection.ensure_connection(): + return HttpResponse("OK") + return HttpResponse(status=500) + + +urlpatterns = [ + # docs UI + re_path( + r"^bootstrap/docs/$", + schema_view.with_ui("swagger", cache_timeout=0), + name="schema-swagger-ui", + ), + # health + path("bootstrap/api/health", health_check), + # admin + path("bootstrap/admin/", admin.site.urls), + # apis + path("api/bootstrap/", include("apps.authentication.urls")), + path("api/", include("apps.organization.urls")), +] diff --git a/bootstrap_service/wsgi.py b/bootstrap_service/wsgi.py new file mode 100644 index 0000000..347b077 --- /dev/null +++ b/bootstrap_service/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for bootstrap_service project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "bootstrap_service.settings") + +application = get_wsgi_application() diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 10614eb..91cbf99 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -20,6 +20,9 @@ until nc -z emqx 18083; do done echo "EMQX is ready" +echo "Running database migrations..." +python manage.py migrate + echo "Running organization initialization..." python manage.py init_organization \ --org-name="${ORG_NAME}" \ @@ -28,3 +31,7 @@ python manage.py init_organization \ --owner-password="${OWNER_PASSWORD}" echo "Organization initialization complete" + +# Start Gunicorn and Celery +gunicorn --worker-class gevent --bind 0.0.0.0:80 --access-logfile - bootstrap_service.wsgi \ +& celery -A bootstrap_service worker -l info -c 1 diff --git a/manage.py b/manage.py index 495ae33..7f2ede6 100644 --- a/manage.py +++ b/manage.py @@ -1,4 +1,17 @@ -#!/usr/bin/env python +# Copyright 2026 Digital Fortress. + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + """Django's command-line utility for administrative tasks.""" import importlib.util import os diff --git a/requirements.txt b/requirements.txt index c546515..18b065c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,18 @@ # Core -Django==3.2.14 +Django==5.0.6 celery==5.4.0 cryptography==43.0.3 python-dotenv==1.0.1 requests==2.31.0 - -# RabbitMQ -pika==1.3.2 \ No newline at end of file +gunicorn==22.0.0 +gevent==24.2.1 +drf-yasg==1.21.7 +djangorestframework==3.15.2 +djangorestframework_simplejwt==5.3.1 +django-cors-headers==4.4.0 +django-filter==24.2 +django-environ==0.11.2 +pika==1.3.2 +django-redis==5.4.0 +boto3==1.37.13 +psycopg2-binary==2.9.9 \ No newline at end of file diff --git a/utils/views.py b/utils/views.py new file mode 100644 index 0000000..6c358fb --- /dev/null +++ b/utils/views.py @@ -0,0 +1,59 @@ +from rest_framework import mixins +from rest_framework.exceptions import ParseError +from rest_framework.generics import GenericAPIView + +from apps.organization.models import Organization + + +class OrganizationAPIView(GenericAPIView): + organization_field = None + + def get_queryset(self): + queryset = super().get_queryset() + + if getattr(self, "swagger_fake_view", False): + return queryset + + if self.organization_field is None: + raise Exception( + "'%s' should either include a `organization_field` attribute, or override the `get_queryset()` method." + % self.__class__.__name__ + ) + + organization_slug_name = self.request.headers.get("X-Organization", None) + if organization_slug_name is None: + raise ParseError("X-Organization header is required") + + filters = { + f"{self.organization_field}__slug_name": organization_slug_name, + f"{self.organization_field}__is_active": True, + } + + return queryset.filter(**filters) + + def create_with_organization(self, serializer): + if "__" not in self.organization_field: + organization = Organization.objects.get( + slug_name=self.request.headers.get("X-Organization") + ) + return serializer.save(**{self.organization_field: organization}) + + return serializer.save() + + +class OrganizationListAPIView(mixins.ListModelMixin, OrganizationAPIView): + """ + Concrete view for listing a queryset of organization. + """ + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class OrganizationRetrieveAPIView(mixins.RetrieveModelMixin, OrganizationAPIView): + """ + Concrete view for retrieving a model instance of organization. + """ + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) From dd0ee8df920358093df20952110882d661faf720 Mon Sep 17 00:00:00 2001 From: ngovinh2k2 Date: Fri, 16 Jan 2026 14:22:45 +0700 Subject: [PATCH 2/2] feat: check slug org api --- apps/organization/urls.py | 7 ++++++- apps/organization/views.py | 21 +++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/organization/urls.py b/apps/organization/urls.py index c3bd2f2..93633a1 100644 --- a/apps/organization/urls.py +++ b/apps/organization/urls.py @@ -1,9 +1,14 @@ from django.urls import path -from apps.organization.views import OrganizationView +from apps.organization.views import CheckOrganizationView, OrganizationView app_name = "organization" urlpatterns = [ path("organizations", OrganizationView.as_view(), name="organization"), + path( + "organizations/check/", + CheckOrganizationView.as_view(), + name="check-organization", + ), ] diff --git a/apps/organization/views.py b/apps/organization/views.py index 4cf0788..66c4971 100644 --- a/apps/organization/views.py +++ b/apps/organization/views.py @@ -1,7 +1,9 @@ from common.pagination.base_pagination import BasePagination from django.db.models import CharField, Count, Subquery from django.shortcuts import get_object_or_404 +from rest_framework import status, views from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.response import Response from apps.organization.models import Organization from apps.organization.serializers import OrganizationSerializer @@ -43,3 +45,22 @@ def get_queryset(self): ) .distinct() ) + + +class CheckOrganizationView(views.APIView): + authentication_classes = [] + + def get(self, request, slug_name): + organization = Organization.objects.filter(slug_name=slug_name).first() + if not organization: + return Response( + {"result": f"Organization with slug '{slug_name}' not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + if not organization.is_active: + result = "The organization is deactivated!" + else: + result = "The organization is valid." + + return Response({"result": result}, status=status.HTTP_200_OK)