Initial commit from monorepo

This commit is contained in:
Ruslan Bakiev
2026-01-07 09:17:34 +07:00
commit 3e2570ae0b
69 changed files with 3777 additions and 0 deletions

24
Dockerfile Normal file
View 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
View 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
View File

17
manage.py Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

28
pyproject.toml Normal file
View 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
View File

@@ -0,0 +1 @@
# Django orders service

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

161
teams/settings.py Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# Orders Django app

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.

45
teams_app/admin.py Normal file
View 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
View 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
View 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"),
)

View 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
View 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

View 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')},
},
),
]

View 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',
},
),
]

View 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),
),
]

View 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',
},
),
]

View 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',
),
]

View File

@@ -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),
),
]

View 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),
),
]

View File

@@ -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
),
),
]

View 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),
),
]

View 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',
),
]

View 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',
},
),
]

View 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),
),
]

View File

165
teams_app/models.py Normal file
View 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
View 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

View 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)

View 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)

View 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)

View 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
View 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', ''),
)

View 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
View 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
View 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)