Initial commit from monorepo
This commit is contained in:
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
NIXPACKS_POETRY_VERSION=2.2.1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN python -m venv --copies /opt/venv
|
||||||
|
ENV VIRTUAL_ENV=/opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \
|
||||||
|
&& poetry install --no-interaction --no-ansi
|
||||||
|
|
||||||
|
ENV PORT=8000
|
||||||
|
|
||||||
|
CMD ["sh", "-c", "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn teams.wsgi:application --bind 0.0.0.0:${PORT:-8000}"]
|
||||||
47
README.md
Normal file
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Teams Service
|
||||||
|
|
||||||
|
Backend сервис для управления командами и участниками в системе Optovia.
|
||||||
|
|
||||||
|
## Описание
|
||||||
|
|
||||||
|
Сервис для управления командами с интеграцией Logto для аутентификации. Включает управление участниками, приглашениями и KYC статусами команд.
|
||||||
|
|
||||||
|
## Основные функции
|
||||||
|
|
||||||
|
- Создание и управление командами
|
||||||
|
- Управление участниками команд (OWNER, ADMIN, MANAGER, MEMBER)
|
||||||
|
- Система приглашений в команды
|
||||||
|
- Интеграция с Logto для аутентификации
|
||||||
|
- KYC статусы команд
|
||||||
|
- Управление активной командой пользователя
|
||||||
|
|
||||||
|
## Модели данных
|
||||||
|
|
||||||
|
- **Team** - модель команды с KYC статусами
|
||||||
|
- **TeamMember** - участники команды с ролями
|
||||||
|
- **TeamInvitation** - приглашения в команды
|
||||||
|
- **User** - пользователи с привязкой к Logto
|
||||||
|
|
||||||
|
## KYC статусы команд
|
||||||
|
|
||||||
|
- `PENDING_KYC` - Требуется KYC
|
||||||
|
- `KYC_IN_REVIEW` - KYC на рассмотрении
|
||||||
|
- `KYC_APPROVED` - KYC одобрен
|
||||||
|
- `KYC_REJECTED` - KYC отклонен
|
||||||
|
- `SUSPENDED` - Заблокировано
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
|
||||||
|
- Django 5.2.8
|
||||||
|
- GraphQL (Graphene-Django)
|
||||||
|
- PostgreSQL
|
||||||
|
- Logto Integration
|
||||||
|
- Gunicorn
|
||||||
|
|
||||||
|
## Развертывание
|
||||||
|
|
||||||
|
Проект развертывается через Nixpacks на Dokploy с автоматическими миграциями.
|
||||||
|
|
||||||
|
## Автор
|
||||||
|
|
||||||
|
Ruslan Bakiev
|
||||||
0
db.sqlite3
Normal file
0
db.sqlite3
Normal file
17
manage.py
Normal file
17
manage.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""Django's command-line utility for administrative tasks."""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
"""Run administrative tasks."""
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'teams.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and "
|
||||||
|
"available on your PYTHONPATH environment variable? Did you "
|
||||||
|
"forget to activate a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
18
nixpacks.toml
Normal file
18
nixpacks.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
providers = ["python"]
|
||||||
|
|
||||||
|
[build]
|
||||||
|
|
||||||
|
[phases.install]
|
||||||
|
cmds = [
|
||||||
|
"python -m venv --copies /opt/venv",
|
||||||
|
". /opt/venv/bin/activate",
|
||||||
|
"pip install poetry==$NIXPACKS_POETRY_VERSION",
|
||||||
|
"poetry install --no-interaction --no-ansi"
|
||||||
|
]
|
||||||
|
|
||||||
|
[start]
|
||||||
|
cmd = "poetry run python manage.py migrate && poetry run python manage.py collectstatic --noinput && poetry run python -m gunicorn teams.wsgi:application --bind 0.0.0.0:${PORT:-8000}"
|
||||||
|
|
||||||
|
[variables]
|
||||||
|
# Set Poetry version to match local environment
|
||||||
|
NIXPACKS_POETRY_VERSION = "2.2.1"
|
||||||
1005
poetry.lock
generated
Normal file
1005
poetry.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
28
pyproject.toml
Normal file
28
pyproject.toml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
[project]
|
||||||
|
name = "teams"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = [
|
||||||
|
{name = "Ruslan Bakiev",email = "572431+veikab@users.noreply.github.com"}
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = "^3.11"
|
||||||
|
dependencies = [
|
||||||
|
"django (>=5.2.8,<6.0)",
|
||||||
|
"graphene-django (>=3.2.3,<4.0.0)",
|
||||||
|
"django-cors-headers (>=4.9.0,<5.0.0)",
|
||||||
|
"psycopg2-binary (>=2.9.11,<3.0.0)",
|
||||||
|
"requests (>=2.32.5,<3.0.0)",
|
||||||
|
"temporalio (>=1.4.0,<2.0.0)",
|
||||||
|
"python-dotenv (>=1.2.1,<2.0.0)",
|
||||||
|
"pyjwt (>=2.10.1,<3.0.0)",
|
||||||
|
"cryptography (>=46.0.3,<47.0.0)",
|
||||||
|
"infisicalsdk (>=1.0.12,<2.0.0)",
|
||||||
|
"gunicorn (>=23.0.0,<24.0.0)",
|
||||||
|
"whitenoise (>=6.7.0,<7.0.0)",
|
||||||
|
"sentry-sdk (>=2.47.0,<3.0.0)"
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
1
teams/__init__.py
Normal file
1
teams/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Django orders service
|
||||||
BIN
teams/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
teams/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams/__pycache__/settings.cpython-313.pyc
Normal file
BIN
teams/__pycache__/settings.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams/__pycache__/settings_local.cpython-313.pyc
Normal file
BIN
teams/__pycache__/settings_local.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams/__pycache__/urls.cpython-313.pyc
Normal file
BIN
teams/__pycache__/urls.cpython-313.pyc
Normal file
Binary file not shown.
161
teams/settings.py
Normal file
161
teams/settings.py
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from infisical_sdk import InfisicalSDKClient
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.django import DjangoIntegration
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
INFISICAL_API_URL = os.environ["INFISICAL_API_URL"]
|
||||||
|
INFISICAL_CLIENT_ID = os.environ["INFISICAL_CLIENT_ID"]
|
||||||
|
INFISICAL_CLIENT_SECRET = os.environ["INFISICAL_CLIENT_SECRET"]
|
||||||
|
INFISICAL_PROJECT_ID = os.environ["INFISICAL_PROJECT_ID"]
|
||||||
|
INFISICAL_ENV = os.environ.get("INFISICAL_ENV", "prod")
|
||||||
|
|
||||||
|
client = InfisicalSDKClient(host=INFISICAL_API_URL)
|
||||||
|
client.auth.universal_auth.login(
|
||||||
|
client_id=INFISICAL_CLIENT_ID,
|
||||||
|
client_secret=INFISICAL_CLIENT_SECRET,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch secrets from /teams and /shared
|
||||||
|
for secret_path in ["/teams", "/shared"]:
|
||||||
|
secrets_response = client.secrets.list_secrets(
|
||||||
|
environment_slug=INFISICAL_ENV,
|
||||||
|
secret_path=secret_path,
|
||||||
|
project_id=INFISICAL_PROJECT_ID,
|
||||||
|
expand_secret_references=True,
|
||||||
|
view_secret_value=True,
|
||||||
|
)
|
||||||
|
for secret in secrets_response.secrets:
|
||||||
|
os.environ[secret.secretKey] = secret.secretValue
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'dev-secret-key-change-in-production')
|
||||||
|
|
||||||
|
DEBUG = os.getenv('DEBUG', 'False') == 'True'
|
||||||
|
|
||||||
|
# Sentry/GlitchTip configuration
|
||||||
|
SENTRY_DSN = os.getenv('SENTRY_DSN', '')
|
||||||
|
if SENTRY_DSN:
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=SENTRY_DSN,
|
||||||
|
integrations=[DjangoIntegration()],
|
||||||
|
auto_session_tracking=False,
|
||||||
|
traces_sample_rate=0.01,
|
||||||
|
release=os.getenv('RELEASE_VERSION', '1.0.0'),
|
||||||
|
environment=os.getenv('ENVIRONMENT', 'production'),
|
||||||
|
send_default_pii=False,
|
||||||
|
debug=DEBUG,
|
||||||
|
)
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
CSRF_TRUSTED_ORIGINS = ['https://teams.optovia.ru']
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'whitenoise.runserver_nostatic',
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'corsheaders',
|
||||||
|
'graphene_django',
|
||||||
|
'teams_app',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'teams.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'teams.wsgi.application'
|
||||||
|
|
||||||
|
db_url = os.environ["TEAMS_DATABASE_URL"]
|
||||||
|
parsed = urlparse(db_url)
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
|
'NAME': parsed.path.lstrip('/'),
|
||||||
|
'USER': parsed.username,
|
||||||
|
'PASSWORD': parsed.password,
|
||||||
|
'HOST': parsed.hostname,
|
||||||
|
'PORT': str(parsed.port) if parsed.port else '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Internationalization
|
||||||
|
LANGUAGE_CODE = 'ru-ru'
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# Static files
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
# Default primary key field type
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = False
|
||||||
|
CORS_ALLOWED_ORIGINS = ['https://optovia.ru']
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'handlers': {
|
||||||
|
'console': {
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'django.request': {
|
||||||
|
'handlers': ['console'],
|
||||||
|
'level': 'DEBUG',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Logto JWT settings
|
||||||
|
LOGTO_JWKS_URL = os.getenv('LOGTO_JWKS_URL', 'https://auth.optovia.ru/oidc/jwks')
|
||||||
|
LOGTO_ISSUER = os.getenv('LOGTO_ISSUER', 'https://auth.optovia.ru/oidc')
|
||||||
|
LOGTO_TEAMS_AUDIENCE = os.getenv('LOGTO_TEAMS_AUDIENCE', 'https://teams.optovia.ru')
|
||||||
|
# ID Token audience can be omitted when we only need signature + issuer validation.
|
||||||
|
LOGTO_ID_TOKEN_AUDIENCE = os.getenv('LOGTO_ID_TOKEN_AUDIENCE')
|
||||||
|
|
||||||
|
# Odoo connection (internal M2M)
|
||||||
|
ODOO_INTERNAL_URL = os.getenv('ODOO_INTERNAL_URL', 'odoo:8069')
|
||||||
71
teams/settings_local.py
Normal file
71
teams/settings_local.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Local settings for makemigrations (no Infisical, SQLite)
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = 'local-dev-key'
|
||||||
|
DEBUG = True
|
||||||
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
'corsheaders',
|
||||||
|
'graphene_django',
|
||||||
|
'teams_app',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'teams.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'ru-ru'
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
USE_I18N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOW_ALL_ORIGINS = False
|
||||||
|
CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'https://optovia.ru']
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
17
teams/urls.py
Normal file
17
teams/urls.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from teams_app.views import test_jwt, PublicGraphQLView, UserGraphQLView, TeamGraphQLView, M2MGraphQLView
|
||||||
|
from teams_app.schemas.public_schema import public_schema
|
||||||
|
from teams_app.schemas.user_schema import user_schema
|
||||||
|
from teams_app.schemas.team_schema import team_schema
|
||||||
|
from teams_app.schemas.m2m_schema import m2m_schema
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('graphql/public/', csrf_exempt(PublicGraphQLView.as_view(graphiql=True, schema=public_schema))),
|
||||||
|
path('graphql/user/', csrf_exempt(UserGraphQLView.as_view(graphiql=True, schema=user_schema))),
|
||||||
|
path('graphql/team/', csrf_exempt(TeamGraphQLView.as_view(graphiql=True, schema=team_schema))),
|
||||||
|
path('graphql/m2m/', csrf_exempt(M2MGraphQLView.as_view(graphiql=True, schema=m2m_schema))),
|
||||||
|
path('test-jwt/', test_jwt, name='test_jwt'),
|
||||||
|
]
|
||||||
6
teams/wsgi.py
Normal file
6
teams/wsgi.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'teams.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
1
teams_app/__init__.py
Normal file
1
teams_app/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Orders Django app
|
||||||
BIN
teams_app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
teams_app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/__pycache__/admin.cpython-313.pyc
Normal file
BIN
teams_app/__pycache__/admin.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/__pycache__/apps.cpython-313.pyc
Normal file
BIN
teams_app/__pycache__/apps.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/__pycache__/auth.cpython-313.pyc
Normal file
BIN
teams_app/__pycache__/auth.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/__pycache__/graphql_middleware.cpython-313.pyc
Normal file
BIN
teams_app/__pycache__/graphql_middleware.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/__pycache__/models.cpython-313.pyc
Normal file
BIN
teams_app/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/__pycache__/permissions.cpython-313.pyc
Normal file
BIN
teams_app/__pycache__/permissions.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/__pycache__/views.cpython-313.pyc
Normal file
BIN
teams_app/__pycache__/views.cpython-313.pyc
Normal file
Binary file not shown.
45
teams_app/admin.py
Normal file
45
teams_app/admin.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from .models import Team, TeamMember, TeamInvitation, TeamInvitationToken, UserProfile, TeamAddress
|
||||||
|
|
||||||
|
@admin.register(Team)
|
||||||
|
class TeamAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('uuid', 'name', 'owner', 'logto_org_id', 'created_at')
|
||||||
|
list_filter = ('created_at',)
|
||||||
|
search_fields = ('name', 'uuid', 'owner__username', 'owner__profile__logto_id', 'logto_org_id')
|
||||||
|
readonly_fields = ('uuid', 'created_at', 'updated_at')
|
||||||
|
|
||||||
|
@admin.register(TeamMember)
|
||||||
|
class TeamMemberAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('uuid', 'team', 'user', 'role', 'joined_at')
|
||||||
|
list_filter = ('role', 'joined_at')
|
||||||
|
search_fields = ('user__username', 'user__profile__logto_id', 'uuid', 'team__name')
|
||||||
|
readonly_fields = ('uuid', 'joined_at')
|
||||||
|
|
||||||
|
@admin.register(TeamInvitation)
|
||||||
|
class TeamInvitationAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('uuid', 'team', 'email', 'role', 'status', 'invited_by', 'expires_at')
|
||||||
|
list_filter = ('role', 'status', 'expires_at')
|
||||||
|
search_fields = ('email', 'uuid', 'team__name', 'invited_by')
|
||||||
|
readonly_fields = ('uuid', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TeamInvitationToken)
|
||||||
|
class TeamInvitationTokenAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('uuid', 'invitation', 'workflow_status', 'expires_at', 'created_at')
|
||||||
|
list_filter = ('workflow_status', 'expires_at')
|
||||||
|
search_fields = ('uuid', 'invitation__email', 'invitation__team__name')
|
||||||
|
readonly_fields = ('uuid', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(UserProfile)
|
||||||
|
class UserProfileAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('logto_id', 'user', 'active_team', 'created_at')
|
||||||
|
search_fields = ('logto_id', 'user__username', 'active_team__name')
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TeamAddress)
|
||||||
|
class TeamAddressAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ('uuid', 'team', 'name', 'address', 'status', 'country_code', 'created_at')
|
||||||
|
list_filter = ('status', 'country_code', 'created_at')
|
||||||
|
search_fields = ('name', 'address', 'uuid', 'team__name')
|
||||||
|
readonly_fields = ('uuid', 'created_at', 'updated_at', 'processed_at')
|
||||||
5
teams_app/apps.py
Normal file
5
teams_app/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class TeamsAppConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'teams_app'
|
||||||
70
teams_app/auth.py
Normal file
70
teams_app/auth.py
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import logging
|
||||||
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from django.conf import settings
|
||||||
|
from jwt import InvalidTokenError, PyJWKClient
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LogtoTokenValidator:
|
||||||
|
"""Validate JWTs issued by Logto using the published JWKS."""
|
||||||
|
|
||||||
|
def __init__(self, jwks_url: str, issuer: str):
|
||||||
|
self._issuer = issuer
|
||||||
|
self._jwks_client = PyJWKClient(jwks_url)
|
||||||
|
|
||||||
|
def decode(
|
||||||
|
self,
|
||||||
|
token: str,
|
||||||
|
audience: Optional[str] = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Decode and verify a JWT, enforcing issuer and optional audience."""
|
||||||
|
try:
|
||||||
|
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
||||||
|
header_alg = jwt.get_unverified_header(token).get("alg")
|
||||||
|
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
signing_key.key,
|
||||||
|
algorithms=[header_alg] if header_alg else None,
|
||||||
|
issuer=self._issuer,
|
||||||
|
audience=audience,
|
||||||
|
options={"verify_aud": audience is not None},
|
||||||
|
)
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
logger.warning("Failed to validate Logto token: %s", exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_bearer_token(request) -> str:
|
||||||
|
"""Extract Bearer token from Authorization header."""
|
||||||
|
auth_header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
raise InvalidTokenError("Missing Bearer token")
|
||||||
|
|
||||||
|
token = auth_header.split(" ", 1)[1]
|
||||||
|
if not token or token == "undefined":
|
||||||
|
raise InvalidTokenError("Empty Bearer token")
|
||||||
|
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_from_payload(payload: dict) -> list[str]:
|
||||||
|
"""Split scope string (if present) into a list."""
|
||||||
|
scope_value = payload.get("scope")
|
||||||
|
if not scope_value:
|
||||||
|
return []
|
||||||
|
if isinstance(scope_value, str):
|
||||||
|
return scope_value.split()
|
||||||
|
if isinstance(scope_value, Iterable):
|
||||||
|
return list(scope_value)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
validator = LogtoTokenValidator(
|
||||||
|
getattr(settings, "LOGTO_JWKS_URL", "https://auth.optovia.ru/oidc/jwks"),
|
||||||
|
getattr(settings, "LOGTO_ISSUER", "https://auth.optovia.ru/oidc"),
|
||||||
|
)
|
||||||
81
teams_app/graphql_middleware.py
Normal file
81
teams_app/graphql_middleware.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
GraphQL middleware for JWT authentication.
|
||||||
|
|
||||||
|
Each class is bound to a specific GraphQL endpoint (public/user/team/m2m).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from django.conf import settings
|
||||||
|
from graphql import GraphQLError
|
||||||
|
from jwt import InvalidTokenError
|
||||||
|
|
||||||
|
from .auth import get_bearer_token, scopes_from_payload, validator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_introspection(info) -> bool:
|
||||||
|
"""Возвращает True для любых introspection резолвов."""
|
||||||
|
field = getattr(info, "field_name", "")
|
||||||
|
parent = getattr(getattr(info, "parent_type", None), "name", "")
|
||||||
|
return field.startswith("__") or parent.startswith("__")
|
||||||
|
|
||||||
|
|
||||||
|
class PublicNoAuthMiddleware:
|
||||||
|
"""Public endpoint - no authentication required."""
|
||||||
|
|
||||||
|
def resolve(self, next, root, info, **kwargs):
|
||||||
|
return next(root, info, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class UserJWTMiddleware:
|
||||||
|
"""User endpoint - requires ID token."""
|
||||||
|
|
||||||
|
def resolve(self, next, root, info, **kwargs):
|
||||||
|
request = info.context
|
||||||
|
if _is_introspection(info):
|
||||||
|
return next(root, info, **kwargs)
|
||||||
|
|
||||||
|
# Only process auth once (check if already processed)
|
||||||
|
if not hasattr(request, 'user_id'):
|
||||||
|
try:
|
||||||
|
token = get_bearer_token(request)
|
||||||
|
payload = validator.decode(token)
|
||||||
|
request.user_id = payload.get('sub')
|
||||||
|
logger.info(f"[UserJWTMiddleware] user_id set to: {request.user_id}")
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
logger.warning(f"[UserJWTMiddleware] Token error: {exc}")
|
||||||
|
raise GraphQLError("Unauthorized") from exc
|
||||||
|
|
||||||
|
return next(root, info, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamJWTMiddleware:
|
||||||
|
"""Team endpoint - requires Access token for teams audience."""
|
||||||
|
|
||||||
|
def resolve(self, next, root, info, **kwargs):
|
||||||
|
request = info.context
|
||||||
|
if _is_introspection(info):
|
||||||
|
return next(root, info, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = get_bearer_token(request)
|
||||||
|
payload = validator.decode(
|
||||||
|
token,
|
||||||
|
audience=getattr(settings, 'LOGTO_TEAMS_AUDIENCE', None),
|
||||||
|
)
|
||||||
|
request.user_id = payload.get('sub')
|
||||||
|
request.team_uuid = payload.get('team_uuid')
|
||||||
|
request.scopes = scopes_from_payload(payload)
|
||||||
|
if not request.team_uuid or 'teams:member' not in request.scopes:
|
||||||
|
raise GraphQLError("Unauthorized")
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
raise GraphQLError("Unauthorized") from exc
|
||||||
|
|
||||||
|
return next(root, info, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class M2MNoAuthMiddleware:
|
||||||
|
"""M2M endpoint - internal services only, no auth for now."""
|
||||||
|
|
||||||
|
def resolve(self, next, root, info, **kwargs):
|
||||||
|
return next(root, info, **kwargs)
|
||||||
56
teams_app/middleware.py
Normal file
56
teams_app/middleware.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.deprecation import MiddlewareMixin
|
||||||
|
from jwt import InvalidTokenError
|
||||||
|
|
||||||
|
from .auth import get_bearer_token, scopes_from_payload, validator
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class LogtoJWTMiddleware(MiddlewareMixin):
|
||||||
|
"""
|
||||||
|
JWT middleware для проверки токенов от Logto
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, get_response=None):
|
||||||
|
super().__init__(get_response)
|
||||||
|
|
||||||
|
# Audience validated only for non-introspection API calls
|
||||||
|
self.audience = getattr(settings, "LOGTO_TEAMS_AUDIENCE", None)
|
||||||
|
|
||||||
|
def _is_introspection_query(self, request):
|
||||||
|
"""Проверяет, является ли запрос introspection (для GraphQL codegen)"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body.decode('utf-8'))
|
||||||
|
query = body.get('query', '')
|
||||||
|
return '__schema' in query or '__type' in query
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def process_request(self, request):
|
||||||
|
"""Обрабатывает JWT токен из заголовка Authorization"""
|
||||||
|
|
||||||
|
# Пропускаем проверку для admin панели и статики
|
||||||
|
if request.path.startswith('/admin/') or request.path.startswith('/static/'):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Пропускаем introspection запросы (для GraphQL codegen)
|
||||||
|
if self._is_introspection_query(request):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = get_bearer_token(request)
|
||||||
|
payload = validator.decode(token, audience=self.audience)
|
||||||
|
|
||||||
|
request.user_id = payload.get('sub')
|
||||||
|
request.team_uuid = payload.get('team_uuid')
|
||||||
|
request.scopes = scopes_from_payload(payload)
|
||||||
|
|
||||||
|
except InvalidTokenError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
68
teams_app/migrations/0001_initial.py
Normal file
68
teams_app/migrations/0001_initial.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-04 08:11
|
||||||
|
|
||||||
|
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),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Team',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('logto_org_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('status', models.CharField(choices=[('PENDING_KYC', 'Требуется KYC'), ('KYC_IN_REVIEW', 'KYC на рассмотрении'), ('KYC_APPROVED', 'KYC одобрен'), ('KYC_REJECTED', 'KYC отклонен'), ('SUSPENDED', 'Заблокировано')], default='PENDING_KYC', max_length=50)),
|
||||||
|
('prefect_flow_run_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='owned_teams', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'teams_team',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TeamInvitation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('role', models.CharField(choices=[('OWNER', 'Владелец'), ('ADMIN', 'Администратор'), ('MANAGER', 'Менеджер'), ('MEMBER', 'Участник')], default='MEMBER', max_length=50)),
|
||||||
|
('status', models.CharField(choices=[('PENDING', 'Ожидает ответа'), ('ACCEPTED', 'Принято'), ('DECLINED', 'Отклонено'), ('EXPIRED', 'Истекло')], default='PENDING', max_length=50)),
|
||||||
|
('invited_by', models.CharField(max_length=255)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='teams_app.team')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'teams_invitation',
|
||||||
|
'unique_together': {('team', 'email')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TeamMember',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
|
||||||
|
('role', models.CharField(choices=[('OWNER', 'Владелец'), ('ADMIN', 'Администратор'), ('MANAGER', 'Менеджер'), ('MEMBER', 'Участник')], default='MEMBER', max_length=50)),
|
||||||
|
('joined_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='members', to='teams_app.team')),
|
||||||
|
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='team_memberships', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'teams_member',
|
||||||
|
'unique_together': {('team', 'user')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
30
teams_app/migrations/0002_add_user_profile.py
Normal file
30
teams_app/migrations/0002_add_user_profile.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserProfile',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('logto_id', models.CharField(max_length=255, unique=True)),
|
||||||
|
('avatar_id', models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
('phone', models.CharField(blank=True, max_length=20, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('active_team', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_profiles', to='teams_app.team')),
|
||||||
|
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'teams_user_profile',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
teams_app/migrations/0003_add_team_type.py
Normal file
18
teams_app/migrations/0003_add_team_type.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-08 09:47
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0002_add_user_profile'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='team_type',
|
||||||
|
field=models.CharField(choices=[('BUYER', 'Покупатель'), ('SELLER', 'Продавец')], default='BUYER', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
teams_app/migrations/0004_teamaddress.py
Normal file
33
teams_app/migrations/0004_teamaddress.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-09 03:18
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0003_add_team_type'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TeamAddress',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
|
||||||
|
('name', models.CharField(max_length=255)),
|
||||||
|
('address', models.TextField()),
|
||||||
|
('latitude', models.FloatField(blank=True, null=True)),
|
||||||
|
('longitude', models.FloatField(blank=True, null=True)),
|
||||||
|
('is_default', models.BooleanField(default=False)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('team', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='addresses', to='teams_app.team')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'teams_address',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
17
teams_app/migrations/0005_remove_team_prefect_flow_run_id.py
Normal file
17
teams_app/migrations/0005_remove_team_prefect_flow_run_id.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-16 01:42
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0004_teamaddress'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='team',
|
||||||
|
name='prefect_flow_run_id',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Generated manually
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0005_remove_team_prefect_flow_run_id'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# TeamAddress fields
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='teamaddress',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('pending', 'Ожидает обработки'),
|
||||||
|
('processing', 'Обрабатывается'),
|
||||||
|
('ready', 'Готов'),
|
||||||
|
('error', 'Ошибка'),
|
||||||
|
],
|
||||||
|
default='pending',
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='teamaddress',
|
||||||
|
name='graph_node_id',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='teamaddress',
|
||||||
|
name='processed_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='teamaddress',
|
||||||
|
name='error_message',
|
||||||
|
field=models.TextField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
# Team selected location fields
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='selected_location_type',
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True,
|
||||||
|
choices=[('address', 'Адрес'), ('hub', 'Хаб')],
|
||||||
|
max_length=20,
|
||||||
|
null=True,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='selected_location_uuid',
|
||||||
|
field=models.CharField(blank=True, max_length=100, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
teams_app/migrations/0007_teamaddress_country_code.py
Normal file
18
teams_app/migrations/0007_teamaddress_country_code.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-16 12:53
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0006_add_address_status_and_selected_location'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='teamaddress',
|
||||||
|
name='country_code',
|
||||||
|
field=models.CharField(blank=True, max_length=2, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated manually
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0007_teamaddress_country_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='teamaddress',
|
||||||
|
name='graph_node_id',
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='teamaddress',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('pending', 'Ожидает обработки'),
|
||||||
|
('processing', 'Обрабатывается'),
|
||||||
|
('ready', 'Готов'),
|
||||||
|
('error', 'Ошибка')
|
||||||
|
],
|
||||||
|
default='processing',
|
||||||
|
max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
teams_app/migrations/0009_alter_teamaddress_status.py
Normal file
18
teams_app/migrations/0009_alter_teamaddress_status.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-30 02:48
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0008_remove_graph_node_id_and_change_default_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='teamaddress',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
17
teams_app/migrations/0010_remove_team_status.py
Normal file
17
teams_app/migrations/0010_remove_team_status.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-30 03:00
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0009_alter_teamaddress_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='team',
|
||||||
|
name='status',
|
||||||
|
),
|
||||||
|
]
|
||||||
30
teams_app/migrations/0011_teaminvitationtoken.py
Normal file
30
teams_app/migrations/0011_teaminvitationtoken.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2025-12-30 07:43
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0010_remove_team_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TeamInvitationToken',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('uuid', models.CharField(default=uuid.uuid4, max_length=100, unique=True)),
|
||||||
|
('token_hash', models.CharField(max_length=255, unique=True)),
|
||||||
|
('workflow_status', models.CharField(choices=[('pending', 'Ожидает обработки'), ('active', 'Активен'), ('error', 'Ошибка')], default='pending', max_length=20)),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('invitation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tokens', to='teams_app.teaminvitation')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'teams_invitation_token',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
28
teams_app/migrations/0012_add_selected_location_details.py
Normal file
28
teams_app/migrations/0012_add_selected_location_details.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.8 on 2026-01-03 05:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('teams_app', '0011_teaminvitationtoken'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='selected_location_latitude',
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='selected_location_longitude',
|
||||||
|
field=models.FloatField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='team',
|
||||||
|
name='selected_location_name',
|
||||||
|
field=models.CharField(blank=True, max_length=255, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
teams_app/migrations/__init__.py
Normal file
0
teams_app/migrations/__init__.py
Normal file
BIN
teams_app/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
BIN
teams_app/migrations/__pycache__/0001_initial.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
teams_app/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
teams_app/migrations/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
165
teams_app/models.py
Normal file
165
teams_app/models.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class Team(models.Model):
|
||||||
|
TEAM_TYPE_CHOICES = [
|
||||||
|
('BUYER', 'Покупатель'),
|
||||||
|
('SELLER', 'Продавец'),
|
||||||
|
]
|
||||||
|
|
||||||
|
LOCATION_TYPE_CHOICES = [
|
||||||
|
('address', 'Адрес'),
|
||||||
|
('hub', 'Хаб'),
|
||||||
|
]
|
||||||
|
|
||||||
|
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
team_type = models.CharField(max_length=20, choices=TEAM_TYPE_CHOICES, default='BUYER')
|
||||||
|
logto_org_id = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='owned_teams',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
selected_location_type = models.CharField(max_length=20, choices=LOCATION_TYPE_CHOICES, null=True, blank=True)
|
||||||
|
selected_location_uuid = models.CharField(max_length=100, null=True, blank=True)
|
||||||
|
selected_location_name = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
selected_location_latitude = models.FloatField(null=True, blank=True)
|
||||||
|
selected_location_longitude = models.FloatField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'teams_team'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Team {self.name}"
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
user = models.OneToOneField(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='profile'
|
||||||
|
)
|
||||||
|
logto_id = models.CharField(max_length=255, unique=True)
|
||||||
|
avatar_id = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
phone = models.CharField(max_length=20, blank=True, null=True)
|
||||||
|
active_team = models.ForeignKey(Team, on_delete=models.SET_NULL, null=True, blank=True, related_name='active_profiles')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'teams_user_profile'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Profile {self.logto_id}"
|
||||||
|
|
||||||
|
class TeamMember(models.Model):
|
||||||
|
ROLE_CHOICES = [
|
||||||
|
('OWNER', 'Владелец'),
|
||||||
|
('ADMIN', 'Администратор'),
|
||||||
|
('MANAGER', 'Менеджер'),
|
||||||
|
('MEMBER', 'Участник'),
|
||||||
|
]
|
||||||
|
|
||||||
|
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
|
||||||
|
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='members')
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='team_memberships',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='MEMBER')
|
||||||
|
joined_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'teams_member'
|
||||||
|
unique_together = ['team', 'user']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.team.name} - {self.user} ({self.role})"
|
||||||
|
|
||||||
|
class TeamInvitation(models.Model):
|
||||||
|
INVITATION_STATUS_CHOICES = [
|
||||||
|
('PENDING', 'Ожидает ответа'),
|
||||||
|
('ACCEPTED', 'Принято'),
|
||||||
|
('DECLINED', 'Отклонено'),
|
||||||
|
('EXPIRED', 'Истекло'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ROLE_CHOICES = TeamMember.ROLE_CHOICES
|
||||||
|
|
||||||
|
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
|
||||||
|
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='invitations')
|
||||||
|
email = models.EmailField()
|
||||||
|
role = models.CharField(max_length=50, choices=ROLE_CHOICES, default='MEMBER')
|
||||||
|
status = models.CharField(max_length=50, choices=INVITATION_STATUS_CHOICES, default='PENDING')
|
||||||
|
invited_by = models.CharField(max_length=255)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'teams_invitation'
|
||||||
|
unique_together = ['team', 'email']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Приглашение в {self.team.name} для {self.email}"
|
||||||
|
|
||||||
|
|
||||||
|
class TeamInvitationToken(models.Model):
|
||||||
|
WORKFLOW_STATUS_CHOICES = [
|
||||||
|
('pending', 'Ожидает обработки'),
|
||||||
|
('active', 'Активен'),
|
||||||
|
('error', 'Ошибка'),
|
||||||
|
]
|
||||||
|
|
||||||
|
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
|
||||||
|
invitation = models.ForeignKey(TeamInvitation, on_delete=models.CASCADE, related_name='tokens')
|
||||||
|
token_hash = models.CharField(max_length=255, unique=True)
|
||||||
|
workflow_status = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=WORKFLOW_STATUS_CHOICES,
|
||||||
|
default='pending',
|
||||||
|
)
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'teams_invitation_token'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Token {self.uuid} for invitation {self.invitation_id}"
|
||||||
|
|
||||||
|
|
||||||
|
class TeamAddress(models.Model):
|
||||||
|
ADDRESS_STATUS_CHOICES = [
|
||||||
|
('pending', 'Ожидает обработки'),
|
||||||
|
('active', 'Активен'),
|
||||||
|
('error', 'Ошибка'),
|
||||||
|
]
|
||||||
|
|
||||||
|
uuid = models.CharField(max_length=100, unique=True, default=uuid.uuid4)
|
||||||
|
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name='addresses')
|
||||||
|
name = models.CharField(max_length=255) # "Офис", "Склад", "Производство"
|
||||||
|
address = models.TextField()
|
||||||
|
latitude = models.FloatField(null=True, blank=True)
|
||||||
|
longitude = models.FloatField(null=True, blank=True)
|
||||||
|
country_code = models.CharField(max_length=2, null=True, blank=True) # ISO 3166-1 alpha-2
|
||||||
|
is_default = models.BooleanField(default=False)
|
||||||
|
status = models.CharField(max_length=20, choices=ADDRESS_STATUS_CHOICES, default='pending')
|
||||||
|
processed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
error_message = models.TextField(null=True, blank=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = 'teams_address'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.team.name} - {self.name}"
|
||||||
74
teams_app/permissions.py
Normal file
74
teams_app/permissions.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
"""
|
||||||
|
Декоратор для проверки scopes в JWT токене.
|
||||||
|
Используется для защиты GraphQL резолверов.
|
||||||
|
"""
|
||||||
|
from functools import wraps
|
||||||
|
from graphql import GraphQLError
|
||||||
|
|
||||||
|
|
||||||
|
def require_scopes(*scopes: str):
|
||||||
|
"""
|
||||||
|
Декоратор для проверки наличия scopes в JWT токене.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
@require_scopes("read:teams")
|
||||||
|
def resolve_team(self, info):
|
||||||
|
...
|
||||||
|
|
||||||
|
@require_scopes("read:teams", "write:teams")
|
||||||
|
def resolve_update_team(self, info):
|
||||||
|
...
|
||||||
|
"""
|
||||||
|
def decorator(func):
|
||||||
|
# Сохраняем scopes в метаданных для возможности сбора всех scopes
|
||||||
|
if not hasattr(func, '_required_scopes'):
|
||||||
|
func._required_scopes = []
|
||||||
|
func._required_scopes.extend(scopes)
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, info, *args, **kwargs):
|
||||||
|
# Получаем scopes из контекста (должны быть добавлены в middleware)
|
||||||
|
user_scopes = set(getattr(info.context, 'scopes', []) or [])
|
||||||
|
|
||||||
|
missing = set(scopes) - user_scopes
|
||||||
|
if missing:
|
||||||
|
raise GraphQLError(f"Missing required scopes: {', '.join(missing)}")
|
||||||
|
|
||||||
|
return func(self, info, *args, **kwargs)
|
||||||
|
|
||||||
|
# Переносим метаданные на wrapper
|
||||||
|
wrapper._required_scopes = func._required_scopes
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def collect_scopes_from_schema(schema) -> set:
|
||||||
|
"""
|
||||||
|
Собирает все scopes из схемы для синхронизации с Logto.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
from .schema import schema
|
||||||
|
scopes = collect_scopes_from_schema(schema)
|
||||||
|
# {'read:team', 'invite:member', ...}
|
||||||
|
"""
|
||||||
|
scopes = set()
|
||||||
|
|
||||||
|
# Query resolvers
|
||||||
|
if hasattr(schema, 'query') and schema.query:
|
||||||
|
query_type = schema.query
|
||||||
|
for field_name in dir(query_type):
|
||||||
|
if field_name.startswith('resolve_'):
|
||||||
|
resolver = getattr(query_type, field_name, None)
|
||||||
|
if resolver and hasattr(resolver, '_required_scopes'):
|
||||||
|
scopes.update(resolver._required_scopes)
|
||||||
|
|
||||||
|
# Mutation resolvers
|
||||||
|
if hasattr(schema, 'mutation') and schema.mutation:
|
||||||
|
mutation_type = schema.mutation
|
||||||
|
for field_name, field in mutation_type._meta.fields.items():
|
||||||
|
if hasattr(field, 'type') and hasattr(field.type, 'mutate'):
|
||||||
|
mutate = field.type.mutate
|
||||||
|
if hasattr(mutate, '_required_scopes'):
|
||||||
|
scopes.update(mutate._required_scopes)
|
||||||
|
|
||||||
|
return scopes
|
||||||
BIN
teams_app/schemas/__pycache__/m2m_schema.cpython-313.pyc
Normal file
BIN
teams_app/schemas/__pycache__/m2m_schema.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/schemas/__pycache__/public_schema.cpython-313.pyc
Normal file
BIN
teams_app/schemas/__pycache__/public_schema.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/schemas/__pycache__/team_schema.cpython-313.pyc
Normal file
BIN
teams_app/schemas/__pycache__/team_schema.cpython-313.pyc
Normal file
Binary file not shown.
BIN
teams_app/schemas/__pycache__/user_schema.cpython-313.pyc
Normal file
BIN
teams_app/schemas/__pycache__/user_schema.cpython-313.pyc
Normal file
Binary file not shown.
329
teams_app/schemas/m2m_schema.py
Normal file
329
teams_app/schemas/m2m_schema.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"""
|
||||||
|
M2M (Machine-to-Machine) GraphQL schema.
|
||||||
|
Used by internal services (Temporal workflows, etc.) without user authentication.
|
||||||
|
"""
|
||||||
|
import graphene
|
||||||
|
import logging
|
||||||
|
from django.utils import timezone
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamAddress as TeamAddressModel, TeamInvitation as TeamInvitationModel, TeamInvitationToken as TeamInvitationTokenModel
|
||||||
|
from .user_schema import _get_or_create_user_with_profile
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Team(DjangoObjectType):
|
||||||
|
id = graphene.String()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TeamModel
|
||||||
|
fields = ('uuid', 'name', 'logto_org_id', 'created_at', 'updated_at')
|
||||||
|
|
||||||
|
def resolve_id(self, info):
|
||||||
|
return self.uuid
|
||||||
|
|
||||||
|
|
||||||
|
class M2MQuery(graphene.ObjectType):
|
||||||
|
team = graphene.Field(Team, teamId=graphene.String(required=True))
|
||||||
|
invitation = graphene.Field(lambda: TeamInvitation, invitationUuid=graphene.String(required=True))
|
||||||
|
|
||||||
|
def resolve_team(self, info, teamId):
|
||||||
|
try:
|
||||||
|
return TeamModel.objects.get(uuid=teamId)
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_invitation(self, info, invitationUuid):
|
||||||
|
try:
|
||||||
|
return TeamInvitationModel.objects.get(uuid=invitationUuid)
|
||||||
|
except TeamInvitationModel.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamInvitation(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = TeamInvitationModel
|
||||||
|
fields = ('uuid', 'team', 'email', 'role', 'status', 'invited_by', 'expires_at', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
class TeamInvitationToken(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = TeamInvitationTokenModel
|
||||||
|
fields = ('uuid', 'invitation', 'workflow_status', 'expires_at', 'created_at')
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvitationFromWorkflowInput(graphene.InputObjectType):
|
||||||
|
teamUuid = graphene.String(required=True)
|
||||||
|
email = graphene.String(required=True)
|
||||||
|
role = graphene.String()
|
||||||
|
invitedBy = graphene.String(required=True)
|
||||||
|
expiresAt = graphene.DateTime(required=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvitationFromWorkflow(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
input = CreateInvitationFromWorkflowInput(required=True)
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
invitationUuid = graphene.String()
|
||||||
|
invitation = graphene.Field(TeamInvitation)
|
||||||
|
|
||||||
|
def mutate(self, info, input):
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=input.teamUuid)
|
||||||
|
invitation = TeamInvitationModel.objects.create(
|
||||||
|
team=team,
|
||||||
|
email=input.email,
|
||||||
|
role=input.role or 'MEMBER',
|
||||||
|
status='PENDING',
|
||||||
|
invited_by=input.invitedBy,
|
||||||
|
expires_at=input.expiresAt,
|
||||||
|
)
|
||||||
|
return CreateInvitationFromWorkflow(
|
||||||
|
success=True,
|
||||||
|
message="Invitation created",
|
||||||
|
invitationUuid=invitation.uuid,
|
||||||
|
invitation=invitation,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to create invitation")
|
||||||
|
return CreateInvitationFromWorkflow(success=False, message=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvitationTokenInput(graphene.InputObjectType):
|
||||||
|
invitationUuid = graphene.String(required=True)
|
||||||
|
tokenHash = graphene.String(required=True)
|
||||||
|
expiresAt = graphene.DateTime(required=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateInvitationToken(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
input = CreateInvitationTokenInput(required=True)
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
token = graphene.Field(TeamInvitationToken)
|
||||||
|
|
||||||
|
def mutate(self, info, input):
|
||||||
|
try:
|
||||||
|
invitation = TeamInvitationModel.objects.get(uuid=input.invitationUuid)
|
||||||
|
token = TeamInvitationTokenModel.objects.create(
|
||||||
|
invitation=invitation,
|
||||||
|
token_hash=input.tokenHash,
|
||||||
|
expires_at=input.expiresAt,
|
||||||
|
workflow_status='pending',
|
||||||
|
)
|
||||||
|
return CreateInvitationToken(success=True, message="Token created", token=token)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Failed to create invitation token")
|
||||||
|
return CreateInvitationToken(success=False, message=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateInvitationTokenStatusInput(graphene.InputObjectType):
|
||||||
|
tokenUuid = graphene.String(required=True)
|
||||||
|
status = graphene.String(required=True) # pending | active | error
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateInvitationTokenStatus(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
input = UpdateInvitationTokenStatusInput(required=True)
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
token = graphene.Field(TeamInvitationToken)
|
||||||
|
|
||||||
|
def mutate(self, info, input):
|
||||||
|
try:
|
||||||
|
token = TeamInvitationTokenModel.objects.get(uuid=input.tokenUuid)
|
||||||
|
token.workflow_status = input.status
|
||||||
|
token.save(update_fields=['workflow_status'])
|
||||||
|
return UpdateInvitationTokenStatus(success=True, message="Status updated", token=token)
|
||||||
|
except TeamInvitationTokenModel.DoesNotExist:
|
||||||
|
return UpdateInvitationTokenStatus(success=False, message="Token not found")
|
||||||
|
|
||||||
|
|
||||||
|
class SetLogtoOrgIdMutation(graphene.Mutation):
|
||||||
|
"""Set Logto organization ID (used by Temporal workflows)"""
|
||||||
|
class Arguments:
|
||||||
|
teamId = graphene.String(required=True)
|
||||||
|
logtoOrgId = graphene.String(required=True)
|
||||||
|
|
||||||
|
team = graphene.Field(Team)
|
||||||
|
success = graphene.Boolean()
|
||||||
|
|
||||||
|
def mutate(self, info, teamId, logtoOrgId):
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=teamId)
|
||||||
|
team.logto_org_id = logtoOrgId
|
||||||
|
team.save(update_fields=['logto_org_id'])
|
||||||
|
logger.info("Team %s: logto_org_id set to %s", teamId, logtoOrgId)
|
||||||
|
return SetLogtoOrgIdMutation(team=team, success=True)
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
raise Exception(f"Team {teamId} not found")
|
||||||
|
|
||||||
|
|
||||||
|
class CreateAddressFromWorkflowMutation(graphene.Mutation):
|
||||||
|
"""Create TeamAddress from Temporal workflow (workflow-first pattern)"""
|
||||||
|
class Arguments:
|
||||||
|
workflowId = graphene.String(required=True)
|
||||||
|
teamUuid = graphene.String(required=True)
|
||||||
|
name = graphene.String(required=True)
|
||||||
|
address = graphene.String(required=True)
|
||||||
|
latitude = graphene.Float()
|
||||||
|
longitude = graphene.Float()
|
||||||
|
countryCode = graphene.String()
|
||||||
|
isDefault = graphene.Boolean()
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
addressUuid = graphene.String()
|
||||||
|
teamType = graphene.String() # "buyer" or "seller"
|
||||||
|
message = graphene.String()
|
||||||
|
|
||||||
|
def mutate(self, info, workflowId, teamUuid, name, address,
|
||||||
|
latitude=None, longitude=None, countryCode=None, isDefault=False):
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=teamUuid)
|
||||||
|
|
||||||
|
# Если новый адрес default - сбрасываем старые
|
||||||
|
if isDefault:
|
||||||
|
team.addresses.update(is_default=False)
|
||||||
|
|
||||||
|
address_obj = TeamAddressModel.objects.create(
|
||||||
|
team=team,
|
||||||
|
name=name,
|
||||||
|
address=address,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
country_code=countryCode,
|
||||||
|
is_default=isDefault,
|
||||||
|
status='pending',
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Created address %s for team %s (workflow=%s, team_type=%s)",
|
||||||
|
address_obj.uuid, teamUuid, workflowId, team.team_type
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateAddressFromWorkflowMutation(
|
||||||
|
success=True,
|
||||||
|
addressUuid=str(address_obj.uuid),
|
||||||
|
teamType=team.team_type.lower() if team.team_type else "buyer",
|
||||||
|
message="Address created"
|
||||||
|
)
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return CreateAddressFromWorkflowMutation(
|
||||||
|
success=False,
|
||||||
|
message=f"Team {teamUuid} not found"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create address: %s", e)
|
||||||
|
return CreateAddressFromWorkflowMutation(
|
||||||
|
success=False,
|
||||||
|
message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTeamFromWorkflowMutation(graphene.Mutation):
|
||||||
|
"""Create Team from Temporal workflow (KYC approval flow)"""
|
||||||
|
class Arguments:
|
||||||
|
teamName = graphene.String(required=True)
|
||||||
|
ownerId = graphene.String(required=True) # Logto user ID
|
||||||
|
teamType = graphene.String() # BUYER | SELLER, default BUYER
|
||||||
|
countryCode = graphene.String()
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
teamId = graphene.String()
|
||||||
|
teamUuid = graphene.String()
|
||||||
|
message = graphene.String()
|
||||||
|
|
||||||
|
def mutate(self, info, teamName, ownerId, teamType=None, countryCode=None):
|
||||||
|
try:
|
||||||
|
# Получаем или создаём пользователя
|
||||||
|
owner = _get_or_create_user_with_profile(ownerId)
|
||||||
|
|
||||||
|
# Создаём команду
|
||||||
|
team = TeamModel.objects.create(
|
||||||
|
name=teamName,
|
||||||
|
owner=owner,
|
||||||
|
team_type=teamType or 'BUYER'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем owner как участника команды с ролью OWNER
|
||||||
|
TeamMemberModel.objects.create(
|
||||||
|
team=team,
|
||||||
|
user=owner,
|
||||||
|
role='OWNER'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Устанавливаем как активную команду
|
||||||
|
if hasattr(owner, 'profile') and not owner.profile.active_team:
|
||||||
|
owner.profile.active_team = team
|
||||||
|
owner.profile.save(update_fields=['active_team'])
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Created team %s (%s) for owner %s from workflow",
|
||||||
|
team.uuid, teamName, ownerId
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateTeamFromWorkflowMutation(
|
||||||
|
success=True,
|
||||||
|
teamId=str(team.id),
|
||||||
|
teamUuid=str(team.uuid),
|
||||||
|
message="Team created"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Failed to create team from workflow: %s", e)
|
||||||
|
return CreateTeamFromWorkflowMutation(
|
||||||
|
success=False,
|
||||||
|
message=str(e)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateAddressStatusMutation(graphene.Mutation):
|
||||||
|
"""Update address processing status (used by Temporal workflows)"""
|
||||||
|
class Arguments:
|
||||||
|
addressUuid = graphene.String(required=True)
|
||||||
|
status = graphene.String(required=True) # pending, active, error
|
||||||
|
errorMessage = graphene.String()
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
|
||||||
|
def mutate(self, info, addressUuid, status, errorMessage=None):
|
||||||
|
try:
|
||||||
|
address = TeamAddressModel.objects.get(uuid=addressUuid)
|
||||||
|
address.status = status
|
||||||
|
update_fields = ['status', 'updated_at']
|
||||||
|
|
||||||
|
if errorMessage:
|
||||||
|
address.error_message = errorMessage
|
||||||
|
update_fields.append('error_message')
|
||||||
|
|
||||||
|
if status == 'active':
|
||||||
|
address.processed_at = timezone.now()
|
||||||
|
update_fields.append('processed_at')
|
||||||
|
|
||||||
|
address.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
logger.info("Address %s status updated to %s", addressUuid, status)
|
||||||
|
|
||||||
|
return UpdateAddressStatusMutation(success=True, message="Status updated")
|
||||||
|
except TeamAddressModel.DoesNotExist:
|
||||||
|
return UpdateAddressStatusMutation(
|
||||||
|
success=False,
|
||||||
|
message=f"Address {addressUuid} not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class M2MMutation(graphene.ObjectType):
|
||||||
|
setLogtoOrgId = SetLogtoOrgIdMutation.Field()
|
||||||
|
createTeamFromWorkflow = CreateTeamFromWorkflowMutation.Field()
|
||||||
|
createAddressFromWorkflow = CreateAddressFromWorkflowMutation.Field()
|
||||||
|
updateAddressStatus = UpdateAddressStatusMutation.Field()
|
||||||
|
createInvitationFromWorkflow = CreateInvitationFromWorkflow.Field()
|
||||||
|
createInvitationToken = CreateInvitationToken.Field()
|
||||||
|
updateInvitationTokenStatus = UpdateInvitationTokenStatus.Field()
|
||||||
|
|
||||||
|
|
||||||
|
m2m_schema = graphene.Schema(query=M2MQuery, mutation=M2MMutation)
|
||||||
12
teams_app/schemas/public_schema.py
Normal file
12
teams_app/schemas/public_schema.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import graphene
|
||||||
|
|
||||||
|
|
||||||
|
class PublicQuery(graphene.ObjectType):
|
||||||
|
"""Public schema - no authentication required"""
|
||||||
|
_placeholder = graphene.String(description="Placeholder field")
|
||||||
|
|
||||||
|
def resolve__placeholder(self, info):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
public_schema = graphene.Schema(query=PublicQuery)
|
||||||
367
teams_app/schemas/team_schema.py
Normal file
367
teams_app/schemas/team_schema.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import graphene
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamAddress as TeamAddressModel
|
||||||
|
from ..permissions import require_scopes
|
||||||
|
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
class User(DjangoObjectType):
|
||||||
|
id = graphene.String()
|
||||||
|
firstName = graphene.String()
|
||||||
|
lastName = graphene.String()
|
||||||
|
phone = graphene.String()
|
||||||
|
avatarId = graphene.String()
|
||||||
|
createdAt = graphene.String()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserModel
|
||||||
|
fields = ('username', 'first_name', 'last_name', 'email')
|
||||||
|
|
||||||
|
def resolve_id(self, info):
|
||||||
|
if hasattr(self, 'profile') and self.profile:
|
||||||
|
return self.profile.logto_id
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
def resolve_firstName(self, info):
|
||||||
|
return self.first_name
|
||||||
|
|
||||||
|
def resolve_lastName(self, info):
|
||||||
|
return self.last_name
|
||||||
|
|
||||||
|
def resolve_phone(self, info):
|
||||||
|
return getattr(self.profile, 'phone', None)
|
||||||
|
|
||||||
|
def resolve_avatarId(self, info):
|
||||||
|
return getattr(self.profile, 'avatar_id', None)
|
||||||
|
|
||||||
|
def resolve_createdAt(self, info):
|
||||||
|
return self.date_joined.isoformat() if self.date_joined else None
|
||||||
|
|
||||||
|
class TeamMember(DjangoObjectType):
|
||||||
|
user = graphene.Field(User)
|
||||||
|
joinedAt = graphene.String()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TeamMemberModel
|
||||||
|
fields = ('role', 'joined_at')
|
||||||
|
|
||||||
|
def resolve_user(self, info):
|
||||||
|
return self.user
|
||||||
|
|
||||||
|
def resolve_joinedAt(self, info):
|
||||||
|
return self.joined_at.isoformat() if self.joined_at else None
|
||||||
|
|
||||||
|
class TeamAddress(DjangoObjectType):
|
||||||
|
isDefault = graphene.Boolean()
|
||||||
|
createdAt = graphene.String()
|
||||||
|
graphNodeId = graphene.String()
|
||||||
|
processedAt = graphene.String()
|
||||||
|
countryCode = graphene.String()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TeamAddressModel
|
||||||
|
fields = ('uuid', 'name', 'address', 'latitude', 'longitude', 'is_default', 'created_at', 'country_code')
|
||||||
|
|
||||||
|
def resolve_isDefault(self, info):
|
||||||
|
return self.is_default
|
||||||
|
|
||||||
|
def resolve_createdAt(self, info):
|
||||||
|
return self.created_at.isoformat() if self.created_at else None
|
||||||
|
|
||||||
|
def resolve_graphNodeId(self, info):
|
||||||
|
return self.graph_node_id
|
||||||
|
|
||||||
|
def resolve_processedAt(self, info):
|
||||||
|
return self.processed_at.isoformat() if self.processed_at else None
|
||||||
|
|
||||||
|
def resolve_countryCode(self, info):
|
||||||
|
return self.country_code
|
||||||
|
|
||||||
|
|
||||||
|
class SelectedLocation(graphene.ObjectType):
|
||||||
|
type = graphene.String()
|
||||||
|
uuid = graphene.String()
|
||||||
|
name = graphene.String()
|
||||||
|
latitude = graphene.Float()
|
||||||
|
longitude = graphene.Float()
|
||||||
|
|
||||||
|
|
||||||
|
class Team(DjangoObjectType):
|
||||||
|
id = graphene.String()
|
||||||
|
ownerId = graphene.String()
|
||||||
|
members = graphene.List(TeamMember)
|
||||||
|
addresses = graphene.List(lambda: TeamAddress)
|
||||||
|
selectedLocation = graphene.Field(SelectedLocation)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TeamModel
|
||||||
|
fields = ('uuid', 'name', 'logto_org_id', 'owner', 'created_at', 'updated_at')
|
||||||
|
|
||||||
|
def resolve_id(self, info):
|
||||||
|
return self.uuid
|
||||||
|
|
||||||
|
def resolve_ownerId(self, info):
|
||||||
|
if self.owner and hasattr(self.owner, 'profile'):
|
||||||
|
return self.owner.profile.logto_id
|
||||||
|
return self.owner.username if self.owner else None
|
||||||
|
|
||||||
|
def resolve_members(self, info):
|
||||||
|
return self.members.all()
|
||||||
|
|
||||||
|
def resolve_addresses(self, info):
|
||||||
|
return self.addresses.all()
|
||||||
|
|
||||||
|
def resolve_selectedLocation(self, info):
|
||||||
|
loc_type = getattr(self, 'selected_location_type', None)
|
||||||
|
loc_uuid = getattr(self, 'selected_location_uuid', None)
|
||||||
|
if loc_type and loc_uuid:
|
||||||
|
return SelectedLocation(
|
||||||
|
type=loc_type,
|
||||||
|
uuid=loc_uuid,
|
||||||
|
name=getattr(self, 'selected_location_name', None),
|
||||||
|
latitude=getattr(self, 'selected_location_latitude', None),
|
||||||
|
longitude=getattr(self, 'selected_location_longitude', None)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamQuery(graphene.ObjectType):
|
||||||
|
team = graphene.Field(Team)
|
||||||
|
getTeam = graphene.Field(Team, teamId=graphene.String(required=True))
|
||||||
|
team_members = graphene.List(TeamMember)
|
||||||
|
team_addresses = graphene.List(TeamAddress)
|
||||||
|
|
||||||
|
@require_scopes("teams:member")
|
||||||
|
def resolve_team(self, info):
|
||||||
|
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||||
|
if not team_uuid:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return TeamModel.objects.get(uuid=team_uuid)
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@require_scopes("teams:member")
|
||||||
|
def resolve_getTeam(self, info, teamId):
|
||||||
|
# Получаем конкретную команду по ID
|
||||||
|
try:
|
||||||
|
return TeamModel.objects.get(uuid=teamId)
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@require_scopes("teams:member")
|
||||||
|
def resolve_team_members(self, info):
|
||||||
|
# Получаем участников команды
|
||||||
|
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||||
|
if not team_uuid:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=team_uuid)
|
||||||
|
return team.members.all()
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return []
|
||||||
|
|
||||||
|
@require_scopes("teams:member")
|
||||||
|
def resolve_team_addresses(self, info):
|
||||||
|
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||||
|
if not team_uuid:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=team_uuid)
|
||||||
|
return team.addresses.all()
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
class InviteMemberInput(graphene.InputObjectType):
|
||||||
|
email = graphene.String(required=True)
|
||||||
|
role = graphene.String()
|
||||||
|
|
||||||
|
class InviteMemberMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
input = InviteMemberInput(required=True)
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
|
||||||
|
@require_scopes("teams:member")
|
||||||
|
def mutate(self, info, input):
|
||||||
|
from ..temporal_client import start_invite_workflow
|
||||||
|
|
||||||
|
# Проверяем права - только owner может приглашать
|
||||||
|
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||||
|
user_id = getattr(info.context, 'user_id', None)
|
||||||
|
|
||||||
|
if not team_uuid or not user_id:
|
||||||
|
return InviteMemberMutation(success=False, message="Недостаточно прав")
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=team_uuid)
|
||||||
|
|
||||||
|
# Проверяем что пользователь - owner команды
|
||||||
|
if not team.owner:
|
||||||
|
return InviteMemberMutation(success=False, message="Только owner может приглашать")
|
||||||
|
owner_identifier = team.owner.profile.logto_id if hasattr(team.owner, 'profile') and team.owner.profile else team.owner.username
|
||||||
|
if owner_identifier != user_id:
|
||||||
|
return InviteMemberMutation(success=False, message="Только owner может приглашать")
|
||||||
|
|
||||||
|
expires_at = timezone.now() + timedelta(days=7)
|
||||||
|
start_invite_workflow(
|
||||||
|
team_uuid=str(team.uuid),
|
||||||
|
email=input.email,
|
||||||
|
role=input.role or 'MEMBER',
|
||||||
|
invited_by=owner_identifier,
|
||||||
|
expires_at=expires_at.isoformat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return InviteMemberMutation(success=True, message="Приглашение отправлено")
|
||||||
|
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return InviteMemberMutation(success=False, message="Команда не найдена")
|
||||||
|
|
||||||
|
class CreateTeamAddressInput(graphene.InputObjectType):
|
||||||
|
name = graphene.String(required=True)
|
||||||
|
address = graphene.String(required=True)
|
||||||
|
latitude = graphene.Float()
|
||||||
|
longitude = graphene.Float()
|
||||||
|
countryCode = graphene.String()
|
||||||
|
isDefault = graphene.Boolean()
|
||||||
|
|
||||||
|
|
||||||
|
class CreateTeamAddressMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
input = CreateTeamAddressInput(required=True)
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
workflowId = graphene.String()
|
||||||
|
|
||||||
|
@require_scopes("teams:member")
|
||||||
|
def mutate(self, info, input):
|
||||||
|
from ..temporal_client import start_address_workflow
|
||||||
|
|
||||||
|
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||||
|
if not team_uuid:
|
||||||
|
return CreateTeamAddressMutation(success=False, message="Не авторизован")
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=team_uuid)
|
||||||
|
|
||||||
|
# Запускаем workflow - он сам создаст адрес через M2M мутацию
|
||||||
|
workflow_id, _ = start_address_workflow(
|
||||||
|
team_uuid=str(team.uuid),
|
||||||
|
name=input.name,
|
||||||
|
address=input.address,
|
||||||
|
latitude=input.get('latitude'),
|
||||||
|
longitude=input.get('longitude'),
|
||||||
|
country_code=input.get('countryCode'),
|
||||||
|
is_default=input.get('isDefault', False),
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateTeamAddressMutation(
|
||||||
|
success=True,
|
||||||
|
message="Адрес создается",
|
||||||
|
workflowId=workflow_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return CreateTeamAddressMutation(success=False, message="Команда не найдена")
|
||||||
|
except Exception as e:
|
||||||
|
return CreateTeamAddressMutation(success=False, message=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteTeamAddressMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
uuid = graphene.String(required=True)
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
|
||||||
|
@require_scopes("teams:member")
|
||||||
|
def mutate(self, info, uuid):
|
||||||
|
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||||
|
if not team_uuid:
|
||||||
|
return DeleteTeamAddressMutation(success=False, message="Не авторизован")
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=team_uuid)
|
||||||
|
address = team.addresses.get(uuid=uuid)
|
||||||
|
address.delete()
|
||||||
|
return DeleteTeamAddressMutation(success=True, message="Адрес удален")
|
||||||
|
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return DeleteTeamAddressMutation(success=False, message="Команда не найдена")
|
||||||
|
except TeamAddressModel.DoesNotExist:
|
||||||
|
return DeleteTeamAddressMutation(success=False, message="Адрес не найден")
|
||||||
|
|
||||||
|
|
||||||
|
class SetSelectedLocationInput(graphene.InputObjectType):
|
||||||
|
type = graphene.String(required=True) # 'address' или 'hub'
|
||||||
|
uuid = graphene.String(required=True)
|
||||||
|
name = graphene.String(required=True)
|
||||||
|
latitude = graphene.Float(required=True)
|
||||||
|
longitude = graphene.Float(required=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SetSelectedLocationMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
input = SetSelectedLocationInput(required=True)
|
||||||
|
|
||||||
|
success = graphene.Boolean()
|
||||||
|
message = graphene.String()
|
||||||
|
selectedLocation = graphene.Field(SelectedLocation)
|
||||||
|
|
||||||
|
@require_scopes("teams:member")
|
||||||
|
def mutate(self, info, input):
|
||||||
|
team_uuid = getattr(info.context, 'team_uuid', None)
|
||||||
|
if not team_uuid:
|
||||||
|
return SetSelectedLocationMutation(success=False, message="Не авторизован")
|
||||||
|
|
||||||
|
location_type = input.type
|
||||||
|
if location_type not in ('address', 'hub'):
|
||||||
|
return SetSelectedLocationMutation(success=False, message="Неверный тип локации")
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=team_uuid)
|
||||||
|
team.selected_location_type = location_type
|
||||||
|
team.selected_location_uuid = input.uuid
|
||||||
|
team.selected_location_name = input.name
|
||||||
|
team.selected_location_latitude = input.latitude
|
||||||
|
team.selected_location_longitude = input.longitude
|
||||||
|
team.save(update_fields=[
|
||||||
|
'selected_location_type',
|
||||||
|
'selected_location_uuid',
|
||||||
|
'selected_location_name',
|
||||||
|
'selected_location_latitude',
|
||||||
|
'selected_location_longitude'
|
||||||
|
])
|
||||||
|
|
||||||
|
return SetSelectedLocationMutation(
|
||||||
|
success=True,
|
||||||
|
message="Локация выбрана",
|
||||||
|
selectedLocation=SelectedLocation(
|
||||||
|
type=location_type,
|
||||||
|
uuid=input.uuid,
|
||||||
|
name=input.name,
|
||||||
|
latitude=input.latitude,
|
||||||
|
longitude=input.longitude
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return SetSelectedLocationMutation(success=False, message="Команда не найдена")
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMutation(graphene.ObjectType):
|
||||||
|
invite_member = InviteMemberMutation.Field()
|
||||||
|
create_team_address = CreateTeamAddressMutation.Field()
|
||||||
|
delete_team_address = DeleteTeamAddressMutation.Field()
|
||||||
|
set_selected_location = SetSelectedLocationMutation.Field()
|
||||||
|
|
||||||
|
team_schema = graphene.Schema(query=TeamQuery, mutation=TeamMutation)
|
||||||
287
teams_app/schemas/user_schema.py
Normal file
287
teams_app/schemas/user_schema.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import graphene
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from ..models import Team as TeamModel, TeamMember as TeamMemberModel, TeamInvitation as TeamInvitationModel, UserProfile
|
||||||
|
from .team_schema import SelectedLocation
|
||||||
|
|
||||||
|
UserModel = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_or_create_user_with_profile(logto_id: str):
|
||||||
|
user, _ = UserModel.objects.get_or_create(
|
||||||
|
username=logto_id,
|
||||||
|
defaults={'email': ''}
|
||||||
|
)
|
||||||
|
profile, _ = UserProfile.objects.get_or_create(
|
||||||
|
logto_id=logto_id,
|
||||||
|
defaults={'user': user}
|
||||||
|
)
|
||||||
|
if profile.user_id != user.id:
|
||||||
|
profile.user = user
|
||||||
|
profile.save(update_fields=['user'])
|
||||||
|
# Attach profile to user for resolvers
|
||||||
|
user.profile = profile
|
||||||
|
return user
|
||||||
|
|
||||||
|
class Team(DjangoObjectType):
|
||||||
|
id = graphene.String()
|
||||||
|
logtoOrgId = graphene.String()
|
||||||
|
teamType = graphene.String()
|
||||||
|
selectedLocation = graphene.Field(SelectedLocation)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TeamModel
|
||||||
|
fields = ('uuid', 'name', 'logto_org_id', 'team_type', 'created_at')
|
||||||
|
|
||||||
|
def resolve_id(self, info):
|
||||||
|
return self.uuid
|
||||||
|
|
||||||
|
def resolve_logtoOrgId(self, info):
|
||||||
|
return self.logto_org_id
|
||||||
|
|
||||||
|
def resolve_teamType(self, info):
|
||||||
|
return self.team_type
|
||||||
|
|
||||||
|
def resolve_selectedLocation(self, info):
|
||||||
|
loc_type = getattr(self, 'selected_location_type', None)
|
||||||
|
loc_uuid = getattr(self, 'selected_location_uuid', None)
|
||||||
|
if loc_type and loc_uuid:
|
||||||
|
return SelectedLocation(
|
||||||
|
type=loc_type,
|
||||||
|
uuid=loc_uuid,
|
||||||
|
name=getattr(self, 'selected_location_name', None),
|
||||||
|
latitude=getattr(self, 'selected_location_latitude', None),
|
||||||
|
longitude=getattr(self, 'selected_location_longitude', None)
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
class User(DjangoObjectType):
|
||||||
|
id = graphene.String()
|
||||||
|
firstName = graphene.String()
|
||||||
|
lastName = graphene.String()
|
||||||
|
phone = graphene.String()
|
||||||
|
avatarId = graphene.String()
|
||||||
|
activeTeamId = graphene.String()
|
||||||
|
activeTeam = graphene.Field(Team)
|
||||||
|
teams = graphene.List(Team)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = UserModel
|
||||||
|
fields = ('username', 'first_name', 'last_name', 'email')
|
||||||
|
|
||||||
|
def resolve_id(self, info):
|
||||||
|
if hasattr(self, 'profile') and self.profile:
|
||||||
|
return self.profile.logto_id
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
def resolve_firstName(self, info):
|
||||||
|
return self.first_name
|
||||||
|
|
||||||
|
def resolve_lastName(self, info):
|
||||||
|
return self.last_name
|
||||||
|
|
||||||
|
def resolve_phone(self, info):
|
||||||
|
return getattr(self.profile, 'phone', None)
|
||||||
|
|
||||||
|
def resolve_avatarId(self, info):
|
||||||
|
return getattr(self.profile, 'avatar_id', None)
|
||||||
|
|
||||||
|
def resolve_activeTeamId(self, info):
|
||||||
|
return self.profile.active_team.uuid if getattr(self, 'profile', None) and self.profile.active_team else None
|
||||||
|
|
||||||
|
def resolve_activeTeam(self, info):
|
||||||
|
return self.profile.active_team if getattr(self, 'profile', None) else None
|
||||||
|
|
||||||
|
def resolve_teams(self, info):
|
||||||
|
# Возвращаем Team объекты через TeamMember отношения
|
||||||
|
from ..models import TeamMember as TeamMemberModel
|
||||||
|
team_members = TeamMemberModel.objects.filter(user=self)
|
||||||
|
return [member.team for member in team_members]
|
||||||
|
|
||||||
|
class TeamMember(DjangoObjectType):
|
||||||
|
user = graphene.Field(User)
|
||||||
|
role = graphene.String()
|
||||||
|
joinedAt = graphene.String()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
from ..models import TeamMember as TeamMemberModel
|
||||||
|
model = TeamMemberModel
|
||||||
|
fields = ('uuid', 'role')
|
||||||
|
|
||||||
|
def resolve_joinedAt(self, info):
|
||||||
|
return self.joined_at.isoformat() if self.joined_at else None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamInvitation(DjangoObjectType):
|
||||||
|
email = graphene.String()
|
||||||
|
role = graphene.String()
|
||||||
|
status = graphene.String()
|
||||||
|
invitedBy = graphene.String()
|
||||||
|
expiresAt = graphene.String()
|
||||||
|
createdAt = graphene.String()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TeamInvitationModel
|
||||||
|
fields = ('uuid', 'email', 'role', 'status')
|
||||||
|
|
||||||
|
def resolve_invitedBy(self, info):
|
||||||
|
return self.invited_by
|
||||||
|
|
||||||
|
def resolve_expiresAt(self, info):
|
||||||
|
return self.expires_at.isoformat() if self.expires_at else None
|
||||||
|
|
||||||
|
def resolve_createdAt(self, info):
|
||||||
|
return self.created_at.isoformat() if self.created_at else None
|
||||||
|
|
||||||
|
|
||||||
|
class TeamWithMembers(DjangoObjectType):
|
||||||
|
id = graphene.String()
|
||||||
|
members = graphene.List(TeamMember)
|
||||||
|
invitations = graphene.List(TeamInvitation)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = TeamModel
|
||||||
|
fields = ('uuid', 'name', 'created_at')
|
||||||
|
|
||||||
|
def resolve_id(self, info):
|
||||||
|
return self.uuid
|
||||||
|
|
||||||
|
def resolve_members(self, info):
|
||||||
|
return self.members.all()
|
||||||
|
|
||||||
|
def resolve_invitations(self, info):
|
||||||
|
return self.invitations.filter(status='PENDING')
|
||||||
|
|
||||||
|
|
||||||
|
class UserQuery(graphene.ObjectType):
|
||||||
|
me = graphene.Field(User)
|
||||||
|
get_team = graphene.Field(TeamWithMembers, team_id=graphene.String(required=True))
|
||||||
|
|
||||||
|
def resolve_me(self, info):
|
||||||
|
# Получаем user_id из ID Token
|
||||||
|
user_id = getattr(info.context, 'user_id', None)
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return _get_or_create_user_with_profile(user_id)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_get_team(self, info, team_id):
|
||||||
|
try:
|
||||||
|
return TeamModel.objects.get(uuid=team_id)
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class CreateTeamInput(graphene.InputObjectType):
|
||||||
|
name = graphene.String(required=True)
|
||||||
|
teamType = graphene.String() # BUYER или SELLER
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserInput(graphene.InputObjectType):
|
||||||
|
firstName = graphene.String()
|
||||||
|
lastName = graphene.String()
|
||||||
|
phone = graphene.String()
|
||||||
|
avatarId = graphene.String()
|
||||||
|
|
||||||
|
class CreateTeamMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
input = CreateTeamInput(required=True)
|
||||||
|
|
||||||
|
team = graphene.Field(Team)
|
||||||
|
|
||||||
|
def mutate(self, info, input):
|
||||||
|
# Получаем user_id из контекста (ID Token)
|
||||||
|
user_id = getattr(info.context, 'user_id', None)
|
||||||
|
if not user_id:
|
||||||
|
raise Exception("User not authenticated")
|
||||||
|
|
||||||
|
try:
|
||||||
|
owner = _get_or_create_user_with_profile(user_id)
|
||||||
|
|
||||||
|
team = TeamModel.objects.create(
|
||||||
|
name=input.name,
|
||||||
|
owner=owner,
|
||||||
|
team_type=input.teamType or 'BUYER'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Добавляем owner как участника команды с ролью OWNER
|
||||||
|
TeamMemberModel.objects.create(
|
||||||
|
team=team,
|
||||||
|
user=owner,
|
||||||
|
role='OWNER'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Устанавливаем как активную команду, если у пользователя её нет
|
||||||
|
if hasattr(owner, 'profile') and not owner.profile.active_team:
|
||||||
|
owner.profile.active_team = team
|
||||||
|
owner.profile.save(update_fields=['active_team'])
|
||||||
|
|
||||||
|
return CreateTeamMutation(team=team)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Failed to create team: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateUserMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
userId = graphene.String(required=True)
|
||||||
|
input = UpdateUserInput(required=True)
|
||||||
|
|
||||||
|
user = graphene.Field(User)
|
||||||
|
|
||||||
|
def mutate(self, info, userId, input):
|
||||||
|
# Проверяем права - пользователь может редактировать только себя
|
||||||
|
context_user_id = getattr(info.context, 'user_id', None)
|
||||||
|
if context_user_id != userId:
|
||||||
|
return UpdateUserMutation(user=None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = _get_or_create_user_with_profile(userId)
|
||||||
|
|
||||||
|
if input.firstName is not None:
|
||||||
|
user.first_name = input.firstName
|
||||||
|
if input.lastName is not None:
|
||||||
|
user.last_name = input.lastName
|
||||||
|
user.save()
|
||||||
|
if hasattr(user, 'profile'):
|
||||||
|
if input.phone is not None:
|
||||||
|
user.profile.phone = input.phone
|
||||||
|
if input.avatarId is not None:
|
||||||
|
user.profile.avatar_id = input.avatarId
|
||||||
|
user.profile.save()
|
||||||
|
return UpdateUserMutation(user=user)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return UpdateUserMutation(user=None)
|
||||||
|
|
||||||
|
|
||||||
|
class SwitchTeamMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
teamId = graphene.String(required=True)
|
||||||
|
|
||||||
|
user = graphene.Field(User)
|
||||||
|
|
||||||
|
def mutate(self, info, teamId):
|
||||||
|
user_id = getattr(info.context, 'user_id', None)
|
||||||
|
if not user_id:
|
||||||
|
raise Exception("User not authenticated")
|
||||||
|
|
||||||
|
try:
|
||||||
|
team = TeamModel.objects.get(uuid=teamId)
|
||||||
|
user = _get_or_create_user_with_profile(user_id)
|
||||||
|
if hasattr(user, 'profile'):
|
||||||
|
user.profile.active_team = team
|
||||||
|
user.profile.save(update_fields=['active_team'])
|
||||||
|
return SwitchTeamMutation(user=user)
|
||||||
|
except TeamModel.DoesNotExist:
|
||||||
|
raise Exception("Team not found")
|
||||||
|
|
||||||
|
|
||||||
|
class UserMutation(graphene.ObjectType):
|
||||||
|
create_team = CreateTeamMutation.Field()
|
||||||
|
update_user = UpdateUserMutation.Field()
|
||||||
|
switch_team = SwitchTeamMutation.Field()
|
||||||
|
|
||||||
|
user_schema = graphene.Schema(query=UserQuery, mutation=UserMutation)
|
||||||
160
teams_app/services.py
Normal file
160
teams_app/services.py
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from .models import Order, OrderLine, Stage, Trip
|
||||||
|
|
||||||
|
class OdooService:
|
||||||
|
def __init__(self):
|
||||||
|
self.base_url = f"http://{settings.ODOO_INTERNAL_URL}"
|
||||||
|
|
||||||
|
def get_odoo_orders(self, team_uuid):
|
||||||
|
"""Получить заказы из Odoo API"""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/fastapi/orders/api/v1/orders/team/{team_uuid}"
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching from Odoo: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def get_odoo_order(self, order_uuid):
|
||||||
|
"""Получить заказ из Odoo API"""
|
||||||
|
try:
|
||||||
|
url = f"{self.base_url}/fastapi/orders/api/v1/orders/{order_uuid}"
|
||||||
|
response = requests.get(url, timeout=10)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.json()
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error fetching order from Odoo: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def sync_team_orders(self, team_uuid):
|
||||||
|
"""Синхронизировать заказы команды с Odoo"""
|
||||||
|
odoo_orders = self.get_odoo_orders(team_uuid)
|
||||||
|
django_orders = []
|
||||||
|
|
||||||
|
for odoo_order in odoo_orders:
|
||||||
|
# Создаем или обновляем заказ в Django
|
||||||
|
order, created = Order.objects.get_or_create(
|
||||||
|
uuid=odoo_order['uuid'],
|
||||||
|
defaults={
|
||||||
|
'name': odoo_order['name'],
|
||||||
|
'team_uuid': odoo_order['teamUuid'],
|
||||||
|
'user_id': odoo_order['userId'],
|
||||||
|
'source_location_uuid': odoo_order['sourceLocationUuid'],
|
||||||
|
'source_location_name': odoo_order['sourceLocationName'],
|
||||||
|
'destination_location_uuid': odoo_order['destinationLocationUuid'],
|
||||||
|
'destination_location_name': odoo_order['destinationLocationName'],
|
||||||
|
'status': odoo_order['status'],
|
||||||
|
'total_amount': odoo_order['totalAmount'],
|
||||||
|
'currency': odoo_order['currency'],
|
||||||
|
'notes': odoo_order.get('notes', ''),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Синхронизируем order lines
|
||||||
|
self.sync_order_lines(order, odoo_order.get('orderLines', []))
|
||||||
|
|
||||||
|
# Синхронизируем stages
|
||||||
|
self.sync_stages(order, odoo_order.get('stages', []))
|
||||||
|
|
||||||
|
django_orders.append(order)
|
||||||
|
|
||||||
|
return django_orders
|
||||||
|
|
||||||
|
def sync_order(self, order_uuid):
|
||||||
|
"""Синхронизировать один заказ с Odoo"""
|
||||||
|
odoo_order = self.get_odoo_order(order_uuid)
|
||||||
|
if not odoo_order:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Создаем или обновляем заказ
|
||||||
|
order, created = Order.objects.get_or_create(
|
||||||
|
uuid=odoo_order['uuid'],
|
||||||
|
defaults={
|
||||||
|
'name': odoo_order['name'],
|
||||||
|
'team_uuid': odoo_order['teamUuid'],
|
||||||
|
'user_id': odoo_order['userId'],
|
||||||
|
'source_location_uuid': odoo_order['sourceLocationUuid'],
|
||||||
|
'source_location_name': odoo_order['sourceLocationName'],
|
||||||
|
'destination_location_uuid': odoo_order['destinationLocationUuid'],
|
||||||
|
'destination_location_name': odoo_order['destinationLocationName'],
|
||||||
|
'status': odoo_order['status'],
|
||||||
|
'total_amount': odoo_order['totalAmount'],
|
||||||
|
'currency': odoo_order['currency'],
|
||||||
|
'notes': odoo_order.get('notes', ''),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Синхронизируем связанные данные
|
||||||
|
self.sync_order_lines(order, odoo_order.get('orderLines', []))
|
||||||
|
self.sync_stages(order, odoo_order.get('stages', []))
|
||||||
|
|
||||||
|
return order
|
||||||
|
|
||||||
|
def sync_order_lines(self, order, odoo_lines):
|
||||||
|
"""Синхронизировать строки заказа"""
|
||||||
|
# Удаляем старые
|
||||||
|
order.order_lines.all().delete()
|
||||||
|
|
||||||
|
# Создаем новые
|
||||||
|
for line_data in odoo_lines:
|
||||||
|
OrderLine.objects.create(
|
||||||
|
uuid=line_data['uuid'],
|
||||||
|
order=order,
|
||||||
|
product_uuid=line_data['productUuid'],
|
||||||
|
product_name=line_data['productName'],
|
||||||
|
quantity=line_data['quantity'],
|
||||||
|
unit=line_data['unit'],
|
||||||
|
price_unit=line_data['priceUnit'],
|
||||||
|
subtotal=line_data['subtotal'],
|
||||||
|
currency=line_data.get('currency', 'RUB'),
|
||||||
|
notes=line_data.get('notes', ''),
|
||||||
|
)
|
||||||
|
|
||||||
|
def sync_stages(self, order, odoo_stages):
|
||||||
|
"""Синхронизировать этапы заказа"""
|
||||||
|
# Удаляем старые
|
||||||
|
order.stages.all().delete()
|
||||||
|
|
||||||
|
# Создаем новые
|
||||||
|
for stage_data in odoo_stages:
|
||||||
|
stage = Stage.objects.create(
|
||||||
|
uuid=stage_data['uuid'],
|
||||||
|
order=order,
|
||||||
|
name=stage_data['name'],
|
||||||
|
sequence=stage_data['sequence'],
|
||||||
|
stage_type=stage_data['stageType'],
|
||||||
|
transport_type=stage_data.get('transportType', ''),
|
||||||
|
source_location_name=stage_data.get('sourceLocationName', ''),
|
||||||
|
destination_location_name=stage_data.get('destinationLocationName', ''),
|
||||||
|
location_name=stage_data.get('locationName', ''),
|
||||||
|
selected_company_uuid=stage_data.get('selectedCompany', {}).get('uuid', '') if stage_data.get('selectedCompany') else '',
|
||||||
|
selected_company_name=stage_data.get('selectedCompany', {}).get('name', '') if stage_data.get('selectedCompany') else '',
|
||||||
|
)
|
||||||
|
|
||||||
|
# Синхронизируем trips
|
||||||
|
self.sync_trips(stage, stage_data.get('trips', []))
|
||||||
|
|
||||||
|
def sync_trips(self, stage, odoo_trips):
|
||||||
|
"""Синхронизировать рейсы этапа"""
|
||||||
|
for trip_data in odoo_trips:
|
||||||
|
Trip.objects.create(
|
||||||
|
uuid=trip_data['uuid'],
|
||||||
|
stage=stage,
|
||||||
|
name=trip_data['name'],
|
||||||
|
sequence=trip_data['sequence'],
|
||||||
|
company_uuid=trip_data.get('company', {}).get('uuid', '') if trip_data.get('company') else '',
|
||||||
|
company_name=trip_data.get('company', {}).get('name', '') if trip_data.get('company') else '',
|
||||||
|
planned_weight=trip_data.get('plannedWeight'),
|
||||||
|
weight_at_loading=trip_data.get('weightAtLoading'),
|
||||||
|
weight_at_unloading=trip_data.get('weightAtUnloading'),
|
||||||
|
planned_loading_date=trip_data.get('plannedLoadingDate'),
|
||||||
|
actual_loading_date=trip_data.get('actualLoadingDate'),
|
||||||
|
real_loading_date=trip_data.get('realLoadingDate'),
|
||||||
|
planned_unloading_date=trip_data.get('plannedUnloadingDate'),
|
||||||
|
actual_unloading_date=trip_data.get('actualUnloadingDate'),
|
||||||
|
notes=trip_data.get('notes', ''),
|
||||||
|
)
|
||||||
168
teams_app/temporal_client.py
Normal file
168
teams_app/temporal_client.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from dataclasses import asdict
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
from temporalio.client import Client
|
||||||
|
|
||||||
|
from .models import Team
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default Temporal connection settings; override via env.
|
||||||
|
TEMPORAL_ADDRESS = os.getenv("TEMPORAL_ADDRESS", "temporal:7233")
|
||||||
|
TEMPORAL_NAMESPACE = os.getenv("TEMPORAL_NAMESPACE", "default")
|
||||||
|
TEMPORAL_TASK_QUEUE = os.getenv("TEMPORAL_TASK_QUEUE", "platform-worker")
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_team_created_async(team: Team) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Start the team_created workflow in Temporal and return (workflow_id, run_id).
|
||||||
|
"""
|
||||||
|
client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE)
|
||||||
|
|
||||||
|
# We re-use team.uuid as workflow_id to keep idempotency.
|
||||||
|
handle = await client.start_workflow(
|
||||||
|
"team_created_workflow", # workflow name registered in worker
|
||||||
|
{
|
||||||
|
"team_id": team.uuid,
|
||||||
|
"team_name": team.name,
|
||||||
|
"owner_id": getattr(getattr(team.owner, "profile", None), "logto_id", "") or getattr(team.owner, "username", ""),
|
||||||
|
"logto_org_id": team.logto_org_id or "",
|
||||||
|
},
|
||||||
|
id=team.uuid,
|
||||||
|
task_queue=TEMPORAL_TASK_QUEUE,
|
||||||
|
)
|
||||||
|
return handle.id, handle.run_id
|
||||||
|
|
||||||
|
|
||||||
|
def start_team_created(team: Team) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Sync wrapper for Django mutation handlers.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return asyncio.run(_start_team_created_async(team))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to start Temporal workflow for team %s", team.uuid)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_address_workflow_async(
|
||||||
|
team_uuid: str,
|
||||||
|
name: str,
|
||||||
|
address: str,
|
||||||
|
latitude: float | None = None,
|
||||||
|
longitude: float | None = None,
|
||||||
|
country_code: str | None = None,
|
||||||
|
is_default: bool = False,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Start the create_address workflow in Temporal.
|
||||||
|
Returns (workflow_id, run_id).
|
||||||
|
"""
|
||||||
|
client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE)
|
||||||
|
|
||||||
|
workflow_id = f"address-{uuid.uuid4()}"
|
||||||
|
|
||||||
|
handle = await client.start_workflow(
|
||||||
|
"create_address",
|
||||||
|
{
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
"team_uuid": team_uuid,
|
||||||
|
"name": name,
|
||||||
|
"address": address,
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
"country_code": country_code,
|
||||||
|
"is_default": is_default,
|
||||||
|
},
|
||||||
|
id=workflow_id,
|
||||||
|
task_queue=TEMPORAL_TASK_QUEUE,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Started address workflow %s for team %s", workflow_id, team_uuid)
|
||||||
|
return handle.id, handle.result_run_id
|
||||||
|
|
||||||
|
|
||||||
|
def start_address_workflow(
|
||||||
|
team_uuid: str,
|
||||||
|
name: str,
|
||||||
|
address: str,
|
||||||
|
latitude: float | None = None,
|
||||||
|
longitude: float | None = None,
|
||||||
|
country_code: str | None = None,
|
||||||
|
is_default: bool = False,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Sync wrapper for starting address workflow.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return asyncio.run(_start_address_workflow_async(
|
||||||
|
team_uuid=team_uuid,
|
||||||
|
name=name,
|
||||||
|
address=address,
|
||||||
|
latitude=latitude,
|
||||||
|
longitude=longitude,
|
||||||
|
country_code=country_code,
|
||||||
|
is_default=is_default,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to start address workflow for team %s", team_uuid)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _start_invite_workflow_async(
|
||||||
|
team_uuid: str,
|
||||||
|
email: str,
|
||||||
|
role: str,
|
||||||
|
invited_by: str,
|
||||||
|
expires_at: str,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Start the invite_user workflow in Temporal.
|
||||||
|
Returns (workflow_id, run_id).
|
||||||
|
"""
|
||||||
|
client = await Client.connect(TEMPORAL_ADDRESS, namespace=TEMPORAL_NAMESPACE)
|
||||||
|
|
||||||
|
workflow_id = f"invite-{uuid.uuid4()}"
|
||||||
|
|
||||||
|
handle = await client.start_workflow(
|
||||||
|
"invite_user",
|
||||||
|
{
|
||||||
|
"team_uuid": team_uuid,
|
||||||
|
"email": email,
|
||||||
|
"role": role,
|
||||||
|
"invited_by": invited_by,
|
||||||
|
"expires_at": expires_at,
|
||||||
|
},
|
||||||
|
id=workflow_id,
|
||||||
|
task_queue=TEMPORAL_TASK_QUEUE,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Started invite workflow %s for %s", workflow_id, email)
|
||||||
|
return handle.id, handle.result_run_id
|
||||||
|
|
||||||
|
|
||||||
|
def start_invite_workflow(
|
||||||
|
team_uuid: str,
|
||||||
|
email: str,
|
||||||
|
role: str,
|
||||||
|
invited_by: str,
|
||||||
|
expires_at: str,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Sync wrapper for starting invite workflow.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return asyncio.run(_start_invite_workflow_async(
|
||||||
|
team_uuid=team_uuid,
|
||||||
|
email=email,
|
||||||
|
role=role,
|
||||||
|
invited_by=invited_by,
|
||||||
|
expires_at=expires_at,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to start invite workflow for %s", email)
|
||||||
|
raise
|
||||||
98
teams_app/tests.py
Normal file
98
teams_app/tests.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from graphene.test import Client
|
||||||
|
from teams_app.schema import schema
|
||||||
|
|
||||||
|
class TeamsGraphQLTestCase(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client(schema)
|
||||||
|
|
||||||
|
def test_get_user_teams_with_params(self):
|
||||||
|
"""Тест getUserTeams с userId"""
|
||||||
|
query = '''
|
||||||
|
{
|
||||||
|
getUserTeams(userId: "demo-user") {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
ownerId
|
||||||
|
logtoOrgId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
result = self.client.execute(query)
|
||||||
|
print(f"\n=== getUserTeams WITH PARAMS ===")
|
||||||
|
print(f"Result: {result}")
|
||||||
|
|
||||||
|
if result.get('errors'):
|
||||||
|
print(f"ERRORS: {result['errors']}")
|
||||||
|
|
||||||
|
if result.get('data'):
|
||||||
|
teams = result['data']['getUserTeams']
|
||||||
|
print(f"Found {len(teams)} teams")
|
||||||
|
for team in teams:
|
||||||
|
print(f"Team: {team.get('name')} - {team.get('id')}")
|
||||||
|
|
||||||
|
# Проверки
|
||||||
|
self.assertIsNone(result.get('errors'))
|
||||||
|
self.assertIn('getUserTeams', result['data'])
|
||||||
|
|
||||||
|
def test_get_user_teams_no_params(self):
|
||||||
|
"""Тест getUserTeams без параметров"""
|
||||||
|
query = '''
|
||||||
|
{
|
||||||
|
getUserTeams {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
result = self.client.execute(query)
|
||||||
|
print(f"\n=== getUserTeams NO PARAMS ===")
|
||||||
|
print(f"Result: {result}")
|
||||||
|
|
||||||
|
if result.get('errors'):
|
||||||
|
print(f"ERRORS: {result['errors']}")
|
||||||
|
|
||||||
|
if result.get('data'):
|
||||||
|
teams = result['data']['getUserTeams']
|
||||||
|
print(f"Found {len(teams)} teams")
|
||||||
|
|
||||||
|
def test_schema_fields(self):
|
||||||
|
"""Тест что схема содержит нужные поля"""
|
||||||
|
query = '''
|
||||||
|
{
|
||||||
|
__type(name: "Team") {
|
||||||
|
fields {
|
||||||
|
name
|
||||||
|
type {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
result = self.client.execute(query)
|
||||||
|
print(f"\n=== TEAM SCHEMA FIELDS ===")
|
||||||
|
|
||||||
|
if result.get('data') and result['data']['__type']:
|
||||||
|
fields = result['data']['__type']['fields']
|
||||||
|
field_names = [f['name'] for f in fields]
|
||||||
|
print(f"Team fields: {field_names}")
|
||||||
|
|
||||||
|
required_fields = ['id', 'name', 'ownerId']
|
||||||
|
for field in required_fields:
|
||||||
|
if field in field_names:
|
||||||
|
print(f"✅ {field} - OK")
|
||||||
|
else:
|
||||||
|
print(f"❌ {field} - MISSING")
|
||||||
|
|
||||||
|
def test_invalid_query(self):
|
||||||
|
"""Тест неправильного запроса"""
|
||||||
|
query = '{ nonExistentField }'
|
||||||
|
result = self.client.execute(query)
|
||||||
|
print(f"\n=== INVALID QUERY TEST ===")
|
||||||
|
print(f"Result: {result}")
|
||||||
|
|
||||||
|
# Должна быть ошибка
|
||||||
|
self.assertIsNotNone(result.get('errors'))
|
||||||
97
teams_app/views.py
Normal file
97
teams_app/views.py
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import json
|
||||||
|
import jwt
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from jwt import InvalidTokenError
|
||||||
|
|
||||||
|
from .auth import get_bearer_token, scopes_from_payload, validator
|
||||||
|
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def test_jwt(request):
|
||||||
|
"""Тестовый endpoint для проверки JWT токена с подписью."""
|
||||||
|
|
||||||
|
try:
|
||||||
|
token = get_bearer_token(request)
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
return JsonResponse({"status": "error", "error": str(exc)}, status=403)
|
||||||
|
|
||||||
|
response = {"token_length": len(token), "token_preview": f"{token[:32]}...{token[-32:]}"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
audience = getattr(settings, "LOGTO_TEAMS_AUDIENCE", None)
|
||||||
|
payload = validator.decode(token, audience=audience)
|
||||||
|
response.update(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"header": jwt.get_unverified_header(token),
|
||||||
|
"payload": payload,
|
||||||
|
"user_id": payload.get("sub"),
|
||||||
|
"team_uuid": payload.get("team_uuid"),
|
||||||
|
"scopes": scopes_from_payload(payload),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return JsonResponse(response, json_dumps_params={"indent": 2})
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
response["status"] = "invalid"
|
||||||
|
response["error"] = str(exc)
|
||||||
|
return JsonResponse(response, status=403, json_dumps_params={"indent": 2})
|
||||||
|
|
||||||
|
|
||||||
|
# GraphQL Views - authentication handled by GRAPHENE MIDDLEWARE
|
||||||
|
|
||||||
|
from graphene_django.views import GraphQLView
|
||||||
|
|
||||||
|
from .graphql_middleware import (
|
||||||
|
M2MNoAuthMiddleware,
|
||||||
|
PublicNoAuthMiddleware,
|
||||||
|
TeamJWTMiddleware,
|
||||||
|
UserJWTMiddleware,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_introspection_query(request):
|
||||||
|
"""Проверяет, является ли запрос introspection (для GraphQL codegen)"""
|
||||||
|
if request.method != 'POST':
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
body = json.loads(request.body.decode('utf-8'))
|
||||||
|
query = body.get('query', '')
|
||||||
|
return '__schema' in query or '__type' in query
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class PublicGraphQLView(GraphQLView):
|
||||||
|
"""GraphQL view for public operations (no authentication)."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs['middleware'] = [PublicNoAuthMiddleware()]
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class UserGraphQLView(GraphQLView):
|
||||||
|
"""GraphQL view for user-level operations (ID Token)."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs['middleware'] = [UserJWTMiddleware()]
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamGraphQLView(GraphQLView):
|
||||||
|
"""GraphQL view for team-level operations (Access Token)."""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs['middleware'] = [TeamJWTMiddleware()]
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class M2MGraphQLView(GraphQLView):
|
||||||
|
"""GraphQL view for M2M (machine-to-machine) operations.
|
||||||
|
No authentication required - used by internal services (Temporal, etc.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
kwargs['middleware'] = [M2MNoAuthMiddleware()]
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
Reference in New Issue
Block a user