Migrate teams backend from Django to Express + Apollo Server + Prisma
Build Docker Image / build (push) Successful in 2m8s

Replace Django/Graphene stack with TypeScript Express server using Apollo Server v4
with 4 GraphQL endpoints (public/user/team/m2m) and Prisma ORM mapped to existing tables.
This commit is contained in:
Ruslan Bakiev
2026-03-09 09:26:41 +07:00
parent e52f5947a2
commit d9f1a066ce
82 changed files with 5164 additions and 3820 deletions
+2
View File
@@ -0,0 +1,2 @@
node_modules
dist
+20 -16
View File
@@ -1,24 +1,28 @@
FROM python:3.12-slim FROM node:22-alpine AS builder
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NIXPACKS_POETRY_VERSION=2.2.1
WORKDIR /app WORKDIR /app
RUN apt-get update \ COPY package.json ./
&& apt-get install -y --no-install-recommends build-essential curl \ RUN npm install
&& rm -rf /var/lib/apt/lists/*
RUN python -m venv --copies /opt/venv COPY prisma ./prisma
ENV VIRTUAL_ENV=/opt/venv RUN npx prisma generate
ENV PATH="/opt/venv/bin:$PATH"
COPY . . COPY tsconfig.json ./
COPY src ./src
RUN npm run build
RUN pip install --no-cache-dir poetry==$NIXPACKS_POETRY_VERSION \ FROM node:22-alpine
&& poetry install --no-interaction --no-ansi
ENV PORT=8000 WORKDIR /app
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}"] COPY package.json ./
RUN npm install --omit=dev
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/dist ./dist
COPY prisma ./prisma
EXPOSE 8000
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/index.js"]
-47
View File
@@ -1,47 +0,0 @@
# 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
View File
-17
View File
@@ -1,17 +0,0 @@
#!/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
View File
@@ -1,18 +0,0 @@
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"
+4183
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "teams",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "prisma generate && tsc",
"start": "prisma migrate deploy && node dist/index.js"
},
"dependencies": {
"@apollo/server": "^4.11.3",
"@prisma/client": "^6.5.0",
"@temporalio/client": "^1.11.7",
"cors": "^2.8.5",
"express": "^4.21.2",
"graphql": "^16.10.0",
"graphql-tag": "^2.12.6",
"jose": "^6.0.11",
"@sentry/node": "^9.5.0"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.13.0",
"prisma": "^6.5.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
Generated
-1005
View File
File diff suppressed because it is too large Load Diff
+132
View File
@@ -0,0 +1,132 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("TEAMS_DATABASE_URL")
}
model Team {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
name String @db.VarChar(200)
teamType String @default("BUYER") @map("team_type") @db.VarChar(20)
logtoOrgId String? @map("logto_org_id") @db.VarChar(100)
ownerId Int? @map("owner_id")
owner User? @relation(fields: [ownerId], references: [id])
selectedLocationType String? @map("selected_location_type") @db.VarChar(20)
selectedLocationUuid String? @map("selected_location_uuid") @db.VarChar(100)
selectedLocationName String? @map("selected_location_name") @db.VarChar(255)
selectedLocationLat Float? @map("selected_location_lat")
selectedLocationLon Float? @map("selected_location_lon")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
members TeamMember[]
invitations TeamInvitation[]
addresses TeamAddress[]
profiles UserProfile[] @relation("ActiveTeam")
@@map("teams_app_team")
}
model User {
id Int @id @default(autoincrement())
password String @default("") @db.VarChar(128)
username String @unique @db.VarChar(150)
firstName String @default("") @map("first_name") @db.VarChar(150)
lastName String @default("") @map("last_name") @db.VarChar(150)
email String @default("") @db.VarChar(254)
isActive Boolean @default(true) @map("is_active")
dateJoined DateTime @default(now()) @map("date_joined")
isSuperuser Boolean @default(false) @map("is_superuser")
isStaff Boolean @default(false) @map("is_staff")
lastLogin DateTime? @map("last_login")
profile UserProfile?
teams Team[]
memberships TeamMember[]
@@map("auth_user")
}
model UserProfile {
id Int @id @default(autoincrement())
userId Int @unique @map("user_id")
user User @relation(fields: [userId], references: [id])
logtoId String @unique @map("logto_id") @db.VarChar(255)
avatarId String? @map("avatar_id") @db.VarChar(255)
phone String @default("") @db.VarChar(50)
activeTeamId Int? @map("active_team_id")
activeTeam Team? @relation("ActiveTeam", fields: [activeTeamId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("teams_app_userprofile")
}
model TeamMember {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamId Int @map("team_id")
team Team @relation(fields: [teamId], references: [id])
userId Int? @map("user_id")
user User? @relation(fields: [userId], references: [id])
role String @default("MEMBER") @db.VarChar(20)
joinedAt DateTime @default(now()) @map("joined_at")
@@unique([teamId, userId])
@@map("teams_app_teammember")
}
model TeamInvitation {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamId Int @map("team_id")
team Team @relation(fields: [teamId], references: [id])
email String @db.VarChar(254)
role String @default("MEMBER") @db.VarChar(20)
status String @default("PENDING") @db.VarChar(20)
invitedBy String @default("") @map("invited_by") @db.VarChar(255)
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
tokens TeamInvitationToken[]
@@unique([teamId, email])
@@map("teams_app_teaminvitation")
}
model TeamInvitationToken {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
invitationId Int @map("invitation_id")
invitation TeamInvitation @relation(fields: [invitationId], references: [id])
tokenHash String @unique @map("token_hash") @db.VarChar(255)
workflowStatus String @default("pending") @map("workflow_status") @db.VarChar(20)
expiresAt DateTime? @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("teams_app_teaminvitationtoken")
}
model TeamAddress {
id Int @id @default(autoincrement())
uuid String @unique @default(uuid())
teamId Int @map("team_id")
team Team @relation(fields: [teamId], references: [id])
name String @db.VarChar(255)
address String
latitude Float?
longitude Float?
countryCode String @default("") @map("country_code") @db.VarChar(10)
isDefault Boolean @default(false) @map("is_default")
status String @default("pending") @db.VarChar(20)
processedAt DateTime? @map("processed_at")
errorMessage String? @map("error_message")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("teams_app_teamaddress")
}
-28
View File
@@ -1,28 +0,0 @@
[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"
+55
View File
@@ -0,0 +1,55 @@
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from 'jose'
import { GraphQLError } from 'graphql'
import type { Request } from 'express'
const LOGTO_JWKS_URL = process.env.LOGTO_JWKS_URL || 'https://auth.optovia.ru/oidc/jwks'
const LOGTO_ISSUER = process.env.LOGTO_ISSUER || 'https://auth.optovia.ru/oidc'
const LOGTO_TEAMS_AUDIENCE = process.env.LOGTO_TEAMS_AUDIENCE || 'https://teams.optovia.ru'
const jwks = createRemoteJWKSet(new URL(LOGTO_JWKS_URL))
export interface AuthContext {
userId?: string
teamUuid?: string
scopes: string[]
isM2M?: boolean
}
function getBearerToken(req: Request): string {
const auth = req.headers.authorization || ''
if (!auth.startsWith('Bearer ')) throw new GraphQLError('Missing Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
const token = auth.slice(7)
if (!token || token === 'undefined') throw new GraphQLError('Empty Bearer token', { extensions: { code: 'UNAUTHENTICATED' } })
return token
}
function scopesFromPayload(payload: JWTPayload): string[] {
const scope = payload.scope
if (!scope) return []
if (typeof scope === 'string') return scope.split(' ')
return []
}
export async function publicContext(): Promise<AuthContext> { return { scopes: [] } }
export async function userContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req)
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER })
return { userId: payload.sub, scopes: [] }
}
export async function teamContext(req: Request): Promise<AuthContext> {
const token = getBearerToken(req)
const { payload } = await jwtVerify(token, jwks, { issuer: LOGTO_ISSUER, audience: LOGTO_TEAMS_AUDIENCE })
const teamUuid = (payload as Record<string, unknown>).team_uuid as string | undefined
const scopes = scopesFromPayload(payload)
if (!teamUuid || !scopes.includes('teams:member')) throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHENTICATED' } })
return { userId: payload.sub, teamUuid, scopes }
}
export async function m2mContext(): Promise<AuthContext> { return { scopes: [], isM2M: true } }
export function requireScopes(ctx: AuthContext, ...required: string[]): void {
const missing = required.filter(s => !ctx.scopes.includes(s))
if (missing.length > 0) throw new GraphQLError(`Missing required scopes: ${missing.join(', ')}`, { extensions: { code: 'FORBIDDEN' } })
}
+3
View File
@@ -0,0 +1,3 @@
import { PrismaClient } from '@prisma/client'
export const prisma = new PrismaClient()
+47
View File
@@ -0,0 +1,47 @@
import express from 'express'
import cors from 'cors'
import { ApolloServer } from '@apollo/server'
import { expressMiddleware } from '@apollo/server/express4'
import * as Sentry from '@sentry/node'
import { publicTypeDefs, publicResolvers } from './schemas/public.js'
import { userTypeDefs, userResolvers } from './schemas/user.js'
import { teamTypeDefs, teamResolvers } from './schemas/team.js'
import { m2mTypeDefs, m2mResolvers } from './schemas/m2m.js'
import { publicContext, userContext, teamContext, m2mContext, type AuthContext } from './auth.js'
const PORT = parseInt(process.env.PORT || '8000', 10)
const SENTRY_DSN = process.env.SENTRY_DSN || ''
if (SENTRY_DSN) {
Sentry.init({
dsn: SENTRY_DSN,
tracesSampleRate: 0.01,
release: process.env.RELEASE_VERSION || '1.0.0',
environment: process.env.ENVIRONMENT || 'production',
})
}
const app = express()
app.use(cors({ origin: ['https://optovia.ru'], credentials: true }))
const publicServer = new ApolloServer<AuthContext>({ typeDefs: publicTypeDefs, resolvers: publicResolvers, introspection: true })
const userServer = new ApolloServer<AuthContext>({ typeDefs: userTypeDefs, resolvers: userResolvers, introspection: true })
const teamServer = new ApolloServer<AuthContext>({ typeDefs: teamTypeDefs, resolvers: teamResolvers, introspection: true })
const m2mServer = new ApolloServer<AuthContext>({ typeDefs: m2mTypeDefs, resolvers: m2mResolvers, introspection: true })
await Promise.all([publicServer.start(), userServer.start(), teamServer.start(), m2mServer.start()])
app.use('/graphql/public', express.json(), expressMiddleware(publicServer, { context: async () => publicContext() }) as unknown as express.RequestHandler)
app.use('/graphql/user', express.json(), expressMiddleware(userServer, { context: async ({ req }) => userContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler)
app.use('/graphql/team', express.json(), expressMiddleware(teamServer, { context: async ({ req }) => teamContext(req as unknown as import('express').Request) }) as unknown as express.RequestHandler)
app.use('/graphql/m2m', express.json(), expressMiddleware(m2mServer, { context: async () => m2mContext() }) as unknown as express.RequestHandler)
app.get('/health', (_, res) => { res.json({ status: 'ok' }) })
app.listen(PORT, '0.0.0.0', () => {
console.log(`Teams server ready on port ${PORT}`)
console.log(` /graphql/public - public`)
console.log(` /graphql/user - id token auth`)
console.log(` /graphql/team - team access token auth`)
console.log(` /graphql/m2m - internal services (no auth)`)
})
+149
View File
@@ -0,0 +1,149 @@
import { prisma } from '../db.js'
export const m2mTypeDefs = `#graphql
type Team {
uuid: String!
name: String!
logtoOrgId: String
createdAt: String
updatedAt: String
}
type TeamInvitation {
uuid: String!
email: String!
role: String!
status: String!
invitedBy: String
expiresAt: String
createdAt: String
}
type TeamInvitationToken {
uuid: String!
workflowStatus: String!
expiresAt: String
createdAt: String
}
input CreateInvitationFromWorkflowInput {
teamUuid: String!
email: String!
role: String
invitedBy: String
expiresAt: String
}
input CreateInvitationTokenInput {
invitationUuid: String!
tokenHash: String!
expiresAt: String
}
input UpdateInvitationTokenStatusInput {
tokenUuid: String!
status: String!
}
type SetLogtoOrgIdResult { team: Team, success: Boolean! }
type CreateTeamResult { success: Boolean!, teamId: String, teamUuid: String, message: String }
type CreateAddressResult { success: Boolean!, addressUuid: String, teamType: String, message: String }
type UpdateAddressStatusResult { success: Boolean! }
type CreateInvitationResult { success: Boolean!, message: String, invitationUuid: String, invitation: TeamInvitation }
type CreateTokenResult { success: Boolean! }
type UpdateTokenStatusResult { success: Boolean! }
type Query {
team(teamId: String!): Team
invitation(invitationUuid: String!): TeamInvitation
}
type Mutation {
setLogtoOrgId(teamId: String!, logtoOrgId: String!): SetLogtoOrgIdResult
createTeamFromWorkflow(teamName: String!, ownerId: String!, teamType: String, countryCode: String): CreateTeamResult
createAddressFromWorkflow(workflowId: String!, teamUuid: String!, name: String!, address: String!, latitude: Float, longitude: Float, countryCode: String, isDefault: Boolean): CreateAddressResult
updateAddressStatus(addressUuid: String!, status: String!, errorMessage: String): UpdateAddressStatusResult
createInvitationFromWorkflow(input: CreateInvitationFromWorkflowInput!): CreateInvitationResult
createInvitationToken(input: CreateInvitationTokenInput!): CreateTokenResult
updateInvitationTokenStatus(input: UpdateInvitationTokenStatusInput!): UpdateTokenStatusResult
}
`
export const m2mResolvers = {
Query: {
team: async (_: unknown, args: { teamId: string }) => {
const team = await prisma.team.findUnique({ where: { uuid: args.teamId } })
if (!team) return null
return { uuid: team.uuid, name: team.name, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString(), updatedAt: team.updatedAt.toISOString() }
},
invitation: async (_: unknown, args: { invitationUuid: string }) => {
const inv = await prisma.teamInvitation.findUnique({ where: { uuid: args.invitationUuid } })
if (!inv) return null
return { uuid: inv.uuid, email: inv.email, role: inv.role, status: inv.status, invitedBy: inv.invitedBy, expiresAt: inv.expiresAt?.toISOString() ?? null, createdAt: inv.createdAt.toISOString() }
},
},
Mutation: {
setLogtoOrgId: async (_: unknown, args: { teamId: string; logtoOrgId: string }) => {
const team = await prisma.team.update({ where: { uuid: args.teamId }, data: { logtoOrgId: args.logtoOrgId } })
return { team: { uuid: team.uuid, name: team.name, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString(), updatedAt: team.updatedAt.toISOString() }, success: true }
},
createTeamFromWorkflow: async (_: unknown, args: { teamName: string; ownerId: string; teamType?: string; countryCode?: string }) => {
try {
let profile = await prisma.userProfile.findUnique({ where: { logtoId: args.ownerId }, include: { user: true } })
if (!profile) {
const user = await prisma.user.create({ data: { username: args.ownerId } })
profile = await prisma.userProfile.create({ data: { userId: user.id, logtoId: args.ownerId }, include: { user: true } })
}
const team = await prisma.team.create({ data: { name: args.teamName, teamType: args.teamType || 'BUYER', ownerId: profile.userId } })
await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } })
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
return { success: true, teamId: team.id.toString(), teamUuid: team.uuid, message: 'Team created' }
} catch (e) {
return { success: false, teamId: null, teamUuid: null, message: String(e) }
}
},
createAddressFromWorkflow: async (_: unknown, args: { workflowId: string; teamUuid: string; name: string; address: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean }) => {
const team = await prisma.team.findUnique({ where: { uuid: args.teamUuid } })
if (!team) return { success: false, addressUuid: null, teamType: null, message: 'Team not found' }
const addr = await prisma.teamAddress.create({
data: { teamId: team.id, name: args.name, address: args.address, latitude: args.latitude, longitude: args.longitude, countryCode: args.countryCode || '', isDefault: args.isDefault || false },
})
return { success: true, addressUuid: addr.uuid, teamType: team.teamType, message: 'Address created' }
},
updateAddressStatus: async (_: unknown, args: { addressUuid: string; status: string; errorMessage?: string }) => {
await prisma.teamAddress.update({
where: { uuid: args.addressUuid },
data: { status: args.status, errorMessage: args.errorMessage || null, processedAt: new Date() },
})
return { success: true }
},
createInvitationFromWorkflow: async (_: unknown, args: { input: { teamUuid: string; email: string; role?: string; invitedBy?: string; expiresAt?: string } }) => {
const team = await prisma.team.findUnique({ where: { uuid: args.input.teamUuid } })
if (!team) return { success: false, message: 'Team not found', invitationUuid: null, invitation: null }
const inv = await prisma.teamInvitation.create({
data: { teamId: team.id, email: args.input.email, role: args.input.role || 'MEMBER', invitedBy: args.input.invitedBy || '', expiresAt: args.input.expiresAt ? new Date(args.input.expiresAt) : null },
})
return { success: true, message: 'Invitation created', invitationUuid: inv.uuid, invitation: { uuid: inv.uuid, email: inv.email, role: inv.role, status: inv.status, invitedBy: inv.invitedBy, expiresAt: inv.expiresAt?.toISOString() ?? null, createdAt: inv.createdAt.toISOString() } }
},
createInvitationToken: async (_: unknown, args: { input: { invitationUuid: string; tokenHash: string; expiresAt?: string } }) => {
const inv = await prisma.teamInvitation.findUnique({ where: { uuid: args.input.invitationUuid } })
if (!inv) return { success: false }
await prisma.teamInvitationToken.create({
data: { invitationId: inv.id, tokenHash: args.input.tokenHash, expiresAt: args.input.expiresAt ? new Date(args.input.expiresAt) : null },
})
return { success: true }
},
updateInvitationTokenStatus: async (_: unknown, args: { input: { tokenUuid: string; status: string } }) => {
await prisma.teamInvitationToken.update({ where: { uuid: args.input.tokenUuid }, data: { workflowStatus: args.input.status } })
return { success: true }
},
},
}
+4
View File
@@ -0,0 +1,4 @@
export const publicTypeDefs = `#graphql
type Query { health: String! }
`
export const publicResolvers = { Query: { health: () => 'ok' } }
+286
View File
@@ -0,0 +1,286 @@
import { GraphQLError } from 'graphql'
import { prisma } from '../db.js'
import { requireScopes, type AuthContext } from '../auth.js'
import { startAddressWorkflow, startInviteWorkflow } from '../services/temporal.js'
export const teamTypeDefs = `#graphql
type TeamUser {
id: String
firstName: String
lastName: String
phone: String
avatarId: String
createdAt: String
}
type TeamMember {
user: TeamUser
role: String!
joinedAt: String
}
type TeamAddress {
uuid: String!
name: String!
address: String!
latitude: Float
longitude: Float
isDefault: Boolean!
countryCode: String
createdAt: String
processedAt: String
status: String!
}
type SelectedLocation {
type: String
uuid: String
name: String
latitude: Float
longitude: Float
}
type Team {
id: String!
name: String!
ownerId: String
members: [TeamMember]
addresses: [TeamAddress]
selectedLocation: SelectedLocation
}
input InviteMemberInput {
email: String!
role: String
}
input CreateTeamAddressInput {
name: String!
address: String!
latitude: Float
longitude: Float
countryCode: String
isDefault: Boolean
}
input UpdateTeamAddressInput {
uuid: String!
name: String
address: String
latitude: Float
longitude: Float
countryCode: String
isDefault: Boolean
}
input SetSelectedLocationInput {
type: String!
uuid: String!
name: String!
latitude: Float!
longitude: Float!
}
type InviteMemberResult { success: Boolean!, message: String }
type CreateTeamAddressResult { success: Boolean!, message: String, workflowId: String }
type UpdateTeamAddressResult { success: Boolean!, address: TeamAddress }
type DeleteTeamAddressResult { success: Boolean! }
type SetSelectedLocationResult { success: Boolean! }
type Query {
team: Team
getTeam(teamId: String!): Team
teamMembers: [TeamMember]
teamAddresses: [TeamAddress]
}
type Mutation {
inviteMember(input: InviteMemberInput!): InviteMemberResult
createTeamAddress(input: CreateTeamAddressInput!): CreateTeamAddressResult
updateTeamAddress(input: UpdateTeamAddressInput!): UpdateTeamAddressResult
deleteTeamAddress(uuid: String!): DeleteTeamAddressResult
setSelectedLocation(input: SetSelectedLocationInput!): SetSelectedLocationResult
}
`
async function getTeamByUuid(uuid: string) {
return prisma.team.findUnique({
where: { uuid },
include: {
members: { include: { user: { include: { profile: true } } } },
addresses: { orderBy: { createdAt: 'desc' } },
},
})
}
function mapTeam(team: NonNullable<Awaited<ReturnType<typeof getTeamByUuid>>>) {
return {
id: team.uuid,
name: team.name,
ownerId: team.ownerId?.toString() ?? null,
members: team.members.map(m => ({
user: m.user ? {
id: m.user.profile?.logtoId ?? m.user.id.toString(),
firstName: m.user.firstName,
lastName: m.user.lastName,
phone: m.user.profile?.phone ?? '',
avatarId: m.user.profile?.avatarId ?? null,
createdAt: m.user.dateJoined.toISOString(),
} : null,
role: m.role,
joinedAt: m.joinedAt.toISOString(),
})),
addresses: team.addresses.map(a => ({
uuid: a.uuid, name: a.name, address: a.address,
latitude: a.latitude, longitude: a.longitude,
isDefault: a.isDefault, countryCode: a.countryCode,
createdAt: a.createdAt.toISOString(),
processedAt: a.processedAt?.toISOString() ?? null,
status: a.status,
})),
selectedLocation: team.selectedLocationType ? {
type: team.selectedLocationType,
uuid: team.selectedLocationUuid,
name: team.selectedLocationName,
latitude: team.selectedLocationLat,
longitude: team.selectedLocationLon,
} : null,
}
}
export const teamResolvers = {
Query: {
team: async (_: unknown, __: unknown, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) throw new GraphQLError('Team UUID not found')
const team = await getTeamByUuid(ctx.teamUuid)
return team ? mapTeam(team) : null
},
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
const team = await getTeamByUuid(args.teamId)
return team ? mapTeam(team) : null
},
teamMembers: async (_: unknown, __: unknown, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) return []
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
if (!team) return []
const members = await prisma.teamMember.findMany({
where: { teamId: team.id },
include: { user: { include: { profile: true } } },
})
return members.map(m => ({
user: m.user ? {
id: m.user.profile?.logtoId ?? m.user.id.toString(),
firstName: m.user.firstName, lastName: m.user.lastName,
phone: m.user.profile?.phone ?? '', avatarId: m.user.profile?.avatarId ?? null,
createdAt: m.user.dateJoined.toISOString(),
} : null,
role: m.role, joinedAt: m.joinedAt.toISOString(),
}))
},
teamAddresses: async (_: unknown, __: unknown, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) return []
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
if (!team) return []
const addrs = await prisma.teamAddress.findMany({ where: { teamId: team.id }, orderBy: { createdAt: 'desc' } })
return addrs.map(a => ({
uuid: a.uuid, name: a.name, address: a.address,
latitude: a.latitude, longitude: a.longitude,
isDefault: a.isDefault, countryCode: a.countryCode,
createdAt: a.createdAt.toISOString(),
processedAt: a.processedAt?.toISOString() ?? null,
status: a.status,
}))
},
},
Mutation: {
inviteMember: async (_: unknown, args: { input: { email: string; role?: string } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid || !ctx.userId) throw new GraphQLError('Not authenticated')
try {
await startInviteWorkflow(ctx.teamUuid, args.input.email, args.input.role || 'MEMBER', ctx.userId)
return { success: true, message: 'Invitation workflow started' }
} catch (e) {
console.error('Failed to start invite workflow:', e)
return { success: false, message: String(e) }
}
},
createTeamAddress: async (_: unknown, args: { input: { name: string; address: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) throw new GraphQLError('Not authenticated')
const team = await prisma.team.findUnique({ where: { uuid: ctx.teamUuid } })
if (!team) throw new GraphQLError('Team not found')
const addr = await prisma.teamAddress.create({
data: {
teamId: team.id, name: args.input.name, address: args.input.address,
latitude: args.input.latitude, longitude: args.input.longitude,
countryCode: args.input.countryCode || '', isDefault: args.input.isDefault || false,
},
})
try {
const wfId = await startAddressWorkflow(
ctx.teamUuid, addr.uuid, addr.name, addr.address,
addr.latitude ?? undefined, addr.longitude ?? undefined,
addr.countryCode || undefined, addr.isDefault,
)
return { success: true, message: 'Address created', workflowId: wfId }
} catch (e) {
console.error('Failed to start address workflow:', e)
return { success: true, message: 'Address created but workflow failed', workflowId: null }
}
},
updateTeamAddress: async (_: unknown, args: { input: { uuid: string; name?: string; address?: string; latitude?: number; longitude?: number; countryCode?: string; isDefault?: boolean } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
const data: Record<string, unknown> = {}
if (args.input.name !== undefined) data.name = args.input.name
if (args.input.address !== undefined) data.address = args.input.address
if (args.input.latitude !== undefined) data.latitude = args.input.latitude
if (args.input.longitude !== undefined) data.longitude = args.input.longitude
if (args.input.countryCode !== undefined) data.countryCode = args.input.countryCode
if (args.input.isDefault !== undefined) data.isDefault = args.input.isDefault
const addr = await prisma.teamAddress.update({ where: { uuid: args.input.uuid }, data })
return {
success: true,
address: {
uuid: addr.uuid, name: addr.name, address: addr.address,
latitude: addr.latitude, longitude: addr.longitude,
isDefault: addr.isDefault, countryCode: addr.countryCode,
createdAt: addr.createdAt.toISOString(),
processedAt: addr.processedAt?.toISOString() ?? null,
status: addr.status,
},
}
},
deleteTeamAddress: async (_: unknown, args: { uuid: string }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
await prisma.teamAddress.delete({ where: { uuid: args.uuid } })
return { success: true }
},
setSelectedLocation: async (_: unknown, args: { input: { type: string; uuid: string; name: string; latitude: number; longitude: number } }, ctx: AuthContext) => {
requireScopes(ctx, 'teams:member')
if (!ctx.teamUuid) throw new GraphQLError('Not authenticated')
await prisma.team.update({
where: { uuid: ctx.teamUuid },
data: {
selectedLocationType: args.input.type,
selectedLocationUuid: args.input.uuid,
selectedLocationName: args.input.name,
selectedLocationLat: args.input.latitude,
selectedLocationLon: args.input.longitude,
},
})
return { success: true }
},
},
}
+189
View File
@@ -0,0 +1,189 @@
import { GraphQLError } from 'graphql'
import { prisma } from '../db.js'
import { startTeamCreated } from '../services/temporal.js'
import type { AuthContext } from '../auth.js'
export const userTypeDefs = `#graphql
type UserTeam {
id: String!
name: String!
teamType: String
logtoOrgId: String
createdAt: String
}
type User {
id: String
firstName: String
lastName: String
phone: String
avatarId: String
activeTeamId: String
activeTeam: UserTeam
teams: [UserTeam]
}
type TeamMemberInfo {
uuid: String!
role: String!
joinedAt: String
}
type TeamInvitationInfo {
uuid: String!
email: String!
role: String!
status: String!
invitedBy: String
expiresAt: String
createdAt: String
}
type TeamWithMembers {
uuid: String!
name: String!
members: [TeamMemberInfo]
invitations: [TeamInvitationInfo]
}
input CreateTeamInput {
name: String!
teamType: String
}
input UpdateUserInput {
firstName: String
lastName: String
phone: String
avatarId: String
}
type CreateTeamResult {
team: UserTeam
success: Boolean!
}
type UpdateUserResult {
user: User
success: Boolean!
}
type SwitchTeamResult {
success: Boolean!
}
type Query {
me: User
getTeam(teamId: String!): TeamWithMembers
}
type Mutation {
createTeam(input: CreateTeamInput!): CreateTeamResult
updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult
switchTeam(teamId: String!): SwitchTeamResult
}
`
async function getOrCreateProfile(logtoId: string) {
let profile = await prisma.userProfile.findUnique({ where: { logtoId: logtoId }, include: { user: true, activeTeam: true } })
if (!profile) {
const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } })
profile = await prisma.userProfile.create({
data: { userId: user.id, logtoId: logtoId },
include: { user: true, activeTeam: true },
})
}
return profile
}
export const userResolvers = {
Query: {
me: async (_: unknown, __: unknown, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId)
const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } })
return {
id: ctx.userId,
firstName: profile.user.firstName,
lastName: profile.user.lastName,
phone: profile.phone,
avatarId: profile.avatarId,
activeTeamId: profile.activeTeam?.uuid ?? null,
activeTeam: profile.activeTeam ? { id: profile.activeTeam.uuid, name: profile.activeTeam.name, teamType: profile.activeTeam.teamType, logtoOrgId: profile.activeTeam.logtoOrgId, createdAt: profile.activeTeam.createdAt.toISOString() } : null,
teams: memberships.map(m => ({ id: m.team.uuid, name: m.team.name, teamType: m.team.teamType, logtoOrgId: m.team.logtoOrgId, createdAt: m.team.createdAt.toISOString() })),
}
},
getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const team = await prisma.team.findUnique({
where: { uuid: args.teamId },
include: {
members: { include: { user: true } },
invitations: true,
},
})
if (!team) return null
return {
uuid: team.uuid,
name: team.name,
members: team.members.map(m => ({ uuid: m.uuid, role: m.role, joinedAt: m.joinedAt.toISOString() })),
invitations: team.invitations.map(i => ({
uuid: i.uuid, email: i.email, role: i.role, status: i.status,
invitedBy: i.invitedBy, expiresAt: i.expiresAt?.toISOString() ?? null,
createdAt: i.createdAt.toISOString(),
})),
}
},
},
Mutation: {
createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId)
const team = await prisma.team.create({
data: { name: args.input.name, teamType: args.input.teamType || 'BUYER', ownerId: profile.userId },
})
await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } })
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
try {
await startTeamCreated(team.uuid, team.name, ctx.userId, team.teamType)
} catch (e) {
console.error('Failed to start team_created workflow:', e)
}
return {
team: { id: team.uuid, name: team.name, teamType: team.teamType, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString() },
success: true,
}
},
updateUser: async (_: unknown, args: { userId: string; input: { firstName?: string; lastName?: string; phone?: string; avatarId?: string } }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId)
const userUpdate: Record<string, unknown> = {}
if (args.input.firstName !== undefined) userUpdate.firstName = args.input.firstName
if (args.input.lastName !== undefined) userUpdate.lastName = args.input.lastName
if (Object.keys(userUpdate).length > 0) {
await prisma.user.update({ where: { id: profile.userId }, data: userUpdate })
}
const profileUpdate: Record<string, unknown> = {}
if (args.input.phone !== undefined) profileUpdate.phone = args.input.phone
if (args.input.avatarId !== undefined) profileUpdate.avatarId = args.input.avatarId
if (Object.keys(profileUpdate).length > 0) {
await prisma.userProfile.update({ where: { id: profile.id }, data: profileUpdate })
}
return { user: { id: ctx.userId }, success: true }
},
switchTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => {
if (!ctx.userId) throw new GraphQLError('Not authenticated')
const profile = await getOrCreateProfile(ctx.userId)
const team = await prisma.team.findUnique({ where: { uuid: args.teamId } })
if (!team) throw new GraphQLError('Team not found')
await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } })
return { success: true }
},
},
}
+46
View File
@@ -0,0 +1,46 @@
import { Client, Connection } from '@temporalio/client'
import { randomUUID } from 'crypto'
const TEMPORAL_HOST = process.env.TEMPORAL_INTERNAL_URL || 'temporal:7233'
const TEMPORAL_NAMESPACE = process.env.TEMPORAL_NAMESPACE || 'default'
const TEMPORAL_TASK_QUEUE = process.env.TEMPORAL_TASK_QUEUE || 'platform-worker'
async function getClient() {
const connection = await Connection.connect({ address: TEMPORAL_HOST })
return new Client({ connection, namespace: TEMPORAL_NAMESPACE })
}
export async function startTeamCreated(teamUuid: string, teamName: string, ownerId: string, teamType: string) {
const client = await getClient()
const handle = await client.workflow.start('team_created_workflow', {
args: [{ team_uuid: teamUuid, team_name: teamName, owner_id: ownerId, team_type: teamType }],
taskQueue: TEMPORAL_TASK_QUEUE,
workflowId: teamUuid,
})
console.log(`Team created workflow started: ${handle.workflowId}`)
return handle.workflowId
}
export async function startAddressWorkflow(teamUuid: string, addressUuid: string, name: string, address: string, latitude?: number, longitude?: number, countryCode?: string, isDefault?: boolean) {
const client = await getClient()
const workflowId = `address-${randomUUID()}`
const handle = await client.workflow.start('create_address', {
args: [{ team_uuid: teamUuid, address_uuid: addressUuid, name, address, latitude, longitude, country_code: countryCode, is_default: isDefault }],
taskQueue: TEMPORAL_TASK_QUEUE,
workflowId,
})
console.log(`Address workflow started: ${handle.workflowId}`)
return handle.workflowId
}
export async function startInviteWorkflow(teamUuid: string, email: string, role: string, invitedBy: string) {
const client = await getClient()
const workflowId = `invite-${randomUUID()}`
const handle = await client.workflow.start('invite_user', {
args: [{ team_uuid: teamUuid, email, role, invited_by: invitedBy }],
taskQueue: TEMPORAL_TASK_QUEUE,
workflowId,
})
console.log(`Invite workflow started: ${handle.workflowId}`)
return handle.workflowId
}
-1
View File
@@ -1 +0,0 @@
# Django orders service
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-161
View File
@@ -1,161 +0,0 @@
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
View File
@@ -1,71 +0,0 @@
# 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
View File
@@ -1,17 +0,0 @@
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
View File
@@ -1,6 +0,0 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'teams.settings')
application = get_wsgi_application()
-1
View File
@@ -1 +0,0 @@
# 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
View File
@@ -1,45 +0,0 @@
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
View File
@@ -1,5 +0,0 @@
from django.apps import AppConfig
class TeamsAppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'teams_app'
-70
View File
@@ -1,70 +0,0 @@
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
View File
@@ -1,81 +0,0 @@
"""
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
View File
@@ -1,56 +0,0 @@
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
View File
@@ -1,68 +0,0 @@
# 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')},
},
),
]
@@ -1,30 +0,0 @@
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',
},
),
]
@@ -1,18 +0,0 @@
# 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
View File
@@ -1,33 +0,0 @@
# 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',
},
),
]
@@ -1,17 +0,0 @@
# 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',
),
]
@@ -1,59 +0,0 @@
# 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),
),
]
@@ -1,18 +0,0 @@
# 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),
),
]
@@ -1,31 +0,0 @@
# 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
),
),
]
@@ -1,18 +0,0 @@
# 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),
),
]
@@ -1,17 +0,0 @@
# 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',
),
]
@@ -1,30 +0,0 @@
# 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',
},
),
]
@@ -1,28 +0,0 @@
# 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
View File
@@ -1,165 +0,0 @@
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
View File
@@ -1,74 +0,0 @@
"""
Декоратор для проверки 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
-329
View File
@@ -1,329 +0,0 @@
"""
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
View File
@@ -1,12 +0,0 @@
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)
-418
View File
@@ -1,418 +0,0 @@
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 UpdateTeamAddressInput(graphene.InputObjectType):
uuid = graphene.String(required=True)
name = graphene.String()
address = graphene.String()
latitude = graphene.Float()
longitude = graphene.Float()
countryCode = graphene.String()
isDefault = graphene.Boolean()
class UpdateTeamAddressMutation(graphene.Mutation):
class Arguments:
input = UpdateTeamAddressInput(required=True)
success = graphene.Boolean()
message = graphene.String()
address = graphene.Field(TeamAddress)
@require_scopes("teams:member")
def mutate(self, info, input):
team_uuid = getattr(info.context, 'team_uuid', None)
if not team_uuid:
return UpdateTeamAddressMutation(success=False, message="Не авторизован")
try:
team = TeamModel.objects.get(uuid=team_uuid)
address = team.addresses.get(uuid=input.uuid)
if input.name is not None:
address.name = input.name
if input.address is not None:
address.address = input.address
if input.latitude is not None:
address.latitude = input.latitude
if input.longitude is not None:
address.longitude = input.longitude
if input.countryCode is not None:
address.country_code = input.countryCode
if input.isDefault is not None:
address.is_default = input.isDefault
address.save()
return UpdateTeamAddressMutation(success=True, message="Адрес обновлен", address=address)
except TeamModel.DoesNotExist:
return UpdateTeamAddressMutation(success=False, message="Команда не найдена")
except TeamAddressModel.DoesNotExist:
return UpdateTeamAddressMutation(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()
update_team_address = UpdateTeamAddressMutation.Field()
delete_team_address = DeleteTeamAddressMutation.Field()
set_selected_location = SetSelectedLocationMutation.Field()
team_schema = graphene.Schema(query=TeamQuery, mutation=TeamMutation)
-287
View File
@@ -1,287 +0,0 @@
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
View File
@@ -1,160 +0,0 @@
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
View File
@@ -1,168 +0,0 @@
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
View File
@@ -1,98 +0,0 @@
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
View File
@@ -1,97 +0,0 @@
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)
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}