feat: add AirShelf core implementation

This commit is contained in:
zyc
2026-06-05 10:21:40 +08:00
parent 2ba1058329
commit cfdcd84a30
252 changed files with 70828 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
DJANGO_SETTINGS_MODULE=airshelf.settings.development
DJANGO_SECRET_KEY=change-me
DJANGO_DEBUG=true
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1
DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
DB_ENGINE=mysql
DB_NAME=airshelf_dev
DB_USER=airshelf
DB_PASSWORD=change-me
DB_HOST=127.0.0.1
DB_PORT=3306
DB_BIND_ADDRESS=
REDIS_CACHE_URL=redis://127.0.0.1:6379/0
CELERY_BROKER_URL=redis://127.0.0.1:6379/1
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/2
REDIS_LOCK_URL=redis://127.0.0.1:6379/3
TOS_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
TOS_BUCKET=airshelf
TOS_ACCESS_KEY_ID=change-me
TOS_SECRET_ACCESS_KEY=change-me
VOLCANO_ARK_API_KEY=change-me
VOLCANO_ARK_BASE_URL=https://ark.cn-beijing.volces.com/api/v3
+44
View File
@@ -0,0 +1,44 @@
# AirShelf Backend
All backend code lives under `AirShelf/core/backend` by project decision.
## Local bootstrap
```bash
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/backend
python3.12 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
python manage.py migrate
python manage.py runserver 0.0.0.0:8000
```
Start workers in separate terminals:
```bash
cd /Users/maidong/Desktop/zyc/qiyuan_gitea/AirShelf/core/backend
source .venv/bin/activate
celery -A airshelf worker -l info
```
`ffmpeg` must be available on `PATH` for Stage5 export jobs.
## Runtime layout
- Django project: `airshelf`
- Domain apps: `apps/*`
- Settings module: `airshelf.settings.development`
- Celery app: `airshelf.celery`
Secrets must be supplied by environment variables or `.env`; never commit values from `account.md`.
## Useful commands
```bash
python manage.py check
python manage.py makemigrations --check --dry-run
python manage.py migrate
python manage.py bootstrap_volcano_models
python manage.py test apps.accounts apps.projects apps.billing
```
+7
View File
@@ -0,0 +1,7 @@
try:
import pymysql
pymysql.install_as_MySQLdb()
except Exception:
pass
+9
View File
@@ -0,0 +1,9 @@
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
application = get_asgi_application()
+11
View File
@@ -0,0 +1,11 @@
import os
from celery import Celery
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
app = Celery("airshelf")
app.config_from_object("django.conf:settings", namespace="CELERY")
app.autodiscover_tasks()
@@ -0,0 +1 @@
+175
View File
@@ -0,0 +1,175 @@
from pathlib import Path
import os
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2]
load_dotenv(BASE_DIR / ".env")
def env(name: str, default: str | None = None) -> str | None:
return os.getenv(name, default)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def env_list(name: str, default: str = "") -> list[str]:
value = os.getenv(name, default)
return [item.strip() for item in value.split(",") if item.strip()]
SECRET_KEY = env("DJANGO_SECRET_KEY", "airshelf-dev-insecure-key")
DEBUG = env_bool("DJANGO_DEBUG", False)
ALLOWED_HOSTS = env_list("DJANGO_ALLOWED_HOSTS", "localhost,127.0.0.1")
CSRF_TRUSTED_ORIGINS = env_list("DJANGO_CSRF_TRUSTED_ORIGINS")
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
"apps.common",
"apps.accounts",
"apps.assets",
"apps.products",
"apps.projects",
"apps.ai",
"apps.billing",
"apps.ops",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"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 = "airshelf.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 = "airshelf.wsgi.application"
ASGI_APPLICATION = "airshelf.asgi.application"
if env("DB_ENGINE", "sqlite") == "mysql":
mysql_options = {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
}
if env("DB_BIND_ADDRESS"):
mysql_options["bind_address"] = env("DB_BIND_ADDRESS")
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": env("DB_NAME", "airshelf"),
"USER": env("DB_USER", "airshelf"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST", "127.0.0.1"),
"PORT": env("DB_PORT", "3306"),
"OPTIONS": mysql_options,
}
}
else:
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
AUTH_USER_MODEL = "accounts.User"
AUTH_PASSWORD_VALIDATORS = [
{"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
"DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
"DEFAULT_FILTER_BACKENDS": [
"rest_framework.filters.SearchFilter",
"rest_framework.filters.OrderingFilter",
],
"PAGE_SIZE": 20,
}
CORS_ALLOWED_ORIGINS = env_list("CORS_ALLOWED_ORIGINS")
CORS_ALLOW_CREDENTIALS = True
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": env("REDIS_CACHE_URL", "redis://127.0.0.1:6379/0"),
}
}
CELERY_BROKER_URL = env("CELERY_BROKER_URL", "redis://127.0.0.1:6379/1")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/2")
CELERY_TASK_ACKS_LATE = True
CELERY_TASK_REJECT_ON_WORKER_LOST = True
CELERY_WORKER_PREFETCH_MULTIPLIER = 1
CELERY_TIMEZONE = TIME_ZONE
REDIS_LOCK_URL = env("REDIS_LOCK_URL", "redis://127.0.0.1:6379/3")
TOS = {
"endpoint": env("TOS_ENDPOINT"),
"bucket": env("TOS_BUCKET"),
"access_key_id": env("TOS_ACCESS_KEY_ID"),
"secret_access_key": env("TOS_SECRET_ACCESS_KEY"),
}
VOLCANO = {
"ark_api_key": env("VOLCANO_ARK_API_KEY"),
"ark_base_url": env("VOLCANO_ARK_BASE_URL", "https://ark.cn-beijing.volces.com/api/v3"),
}
DEFAULT_TRIAL_CREDITS = env("DEFAULT_TRIAL_CREDITS", "100.0000")
@@ -0,0 +1,5 @@
from .base import * # noqa: F403
DEBUG = True
@@ -0,0 +1,8 @@
from .base import * # noqa: F403
DEBUG = False
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
+13
View File
@@ -0,0 +1,13 @@
from .base import * # noqa: F403
DEBUG = False
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": ":memory:",
}
}
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
CELERY_TASK_ALWAYS_EAGER = True
CELERY_TASK_EAGER_PROPAGATES = True
+17
View File
@@ -0,0 +1,17 @@
from django.contrib import admin
from django.urls import include, path
from apps.common.views import health_check
urlpatterns = [
path("admin/", admin.site.urls),
path("api/health/", health_check, name="health-check"),
path("api/auth/", include("apps.accounts.urls")),
path("api/products/", include("apps.products.urls")),
path("api/assets/", include("apps.assets.urls")),
path("api/projects/", include("apps.projects.urls")),
path("api/billing/", include("apps.billing.urls")),
path("api/ai/", include("apps.ai.urls")),
path("api/ops/", include("apps.ops.urls")),
]
+9
View File
@@ -0,0 +1,9 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "airshelf.settings.development")
application = get_wsgi_application()
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+25
View File
@@ -0,0 +1,25 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import Team, TeamMember, User
@admin.register(User)
class AirShelfUserAdmin(UserAdmin):
list_display = ("username", "email", "status", "is_staff", "date_joined")
list_filter = ("status", "is_staff", "is_superuser")
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "status", "created_at")
search_fields = ("name", "owner__username", "owner__email")
list_filter = ("status",)
@admin.register(TeamMember)
class TeamMemberAdmin(admin.ModelAdmin):
list_display = ("team", "user", "role", "status", "monthly_credit_limit")
search_fields = ("team__name", "user__username", "user__email")
list_filter = ("role", "status")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
@@ -0,0 +1,245 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("phone", models.CharField(blank=True, max_length=32)),
("avatar_url", models.URLField(blank=True)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="Team",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_teams",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TeamMember",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"role",
models.CharField(
choices=[
("owner", "Owner"),
("admin", "Admin"),
("member", "Member"),
("viewer", "Viewer"),
],
default="member",
max_length=24,
),
),
(
"status",
models.CharField(
choices=[
("active", "Active"),
("invited", "Invited"),
("disabled", "Disabled"),
],
default="active",
max_length=24,
),
),
(
"monthly_credit_limit",
models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="team_memberships",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("team", "user")},
},
),
]
@@ -0,0 +1 @@
+59
View File
@@ -0,0 +1,59 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
from apps.common.models import TimeStampedModel
class User(AbstractUser):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
phone = models.CharField(max_length=32, blank=True)
avatar_url = models.URLField(blank=True)
@property
def is_disabled(self) -> bool:
return self.status == self.Status.DISABLED
class Team(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name="owned_teams")
def __str__(self) -> str:
return self.name
class TeamMember(TimeStampedModel):
class Role(models.TextChoices):
OWNER = "owner", "Owner"
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
class Status(models.TextChoices):
ACTIVE = "active", "Active"
INVITED = "invited", "Invited"
DISABLED = "disabled", "Disabled"
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="members")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="team_memberships")
role = models.CharField(max_length=24, choices=Role.choices, default=Role.MEMBER)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
class Meta:
unique_together = [("team", "user")]
def __str__(self) -> str:
return f"{self.team} / {self.user} / {self.role}"
+68
View File
@@ -0,0 +1,68 @@
from rest_framework import serializers
from apps.billing.models import CreditAccount
from .models import Team, TeamMember, User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "first_name", "last_name", "email", "phone", "avatar_url", "status"]
read_only_fields = ["id", "status"]
class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = ["id", "name", "status", "owner", "created_at", "updated_at"]
read_only_fields = ["id", "status", "owner", "created_at", "updated_at"]
class TeamMemberSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = TeamMember
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit"]
read_only_fields = ["id", "team", "user", "status"]
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
password = serializers.CharField(min_length=8, write_only=True)
email = serializers.EmailField(required=False, allow_blank=True)
team_name = serializers.CharField(max_length=128, required=False, allow_blank=True)
def validate_username(self, value):
if User.objects.filter(username=value).exists():
raise serializers.ValidationError("username already exists")
return value
def create(self, validated_data):
from decimal import Decimal
from django.conf import settings
from django.db import transaction
with transaction.atomic():
user = User.objects.create_user(
username=validated_data["username"],
password=validated_data["password"],
email=validated_data.get("email", ""),
)
team = Team.objects.create(
name=validated_data.get("team_name") or f"{user.username}'s Team",
owner=user,
)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(
team=team,
balance=Decimal(str(settings.DEFAULT_TRIAL_CREDITS)),
)
return {"user": user, "team": team}
class LoginSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField(write_only=True)
+29
View File
@@ -0,0 +1,29 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.billing.models import CreditAccount
class AuthApiTests(TestCase):
def test_register_creates_user_team_member_credit_account_and_token(self):
client = APIClient()
response = client.post(
"/api/auth/register/",
{
"username": "new-owner",
"password": "strong-password",
"email": "owner@example.com",
"team_name": "Launch Team",
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertIn("token", response.data)
user = User.objects.get(username="new-owner")
team = Team.objects.get(name="Launch Team")
self.assertEqual(team.owner, user)
self.assertTrue(TeamMember.objects.filter(team=team, user=user, role=TeamMember.Role.OWNER).exists())
self.assertTrue(CreditAccount.objects.filter(team=team).exists())
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from .views import login, logout, me, register, team_member_detail, team_member_password, team_members
urlpatterns = [
path("register/", register, name="auth-register"),
path("login/", login, name="auth-login"),
path("logout/", logout, name="auth-logout"),
path("me/", me, name="auth-me"),
path("team/members/", team_members, name="team-members"),
path("team/members/<uuid:member_id>/", team_member_detail, name="team-member-detail"),
path("team/members/<uuid:member_id>/password/", team_member_password, name="team-member-password"),
]
+170
View File
@@ -0,0 +1,170 @@
from django.contrib.auth import authenticate
from django.db import transaction
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import TeamMember, User
from .serializers import LoginSerializer, RegisterSerializer, TeamMemberSerializer, TeamSerializer, UserSerializer
def auth_payload(user, team, token):
return {
"token": token.key,
"user": UserSerializer(user).data,
"team": TeamSerializer(team).data,
}
@api_view(["POST"])
@permission_classes([])
def register(request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.save()
token, _ = Token.objects.get_or_create(user=data["user"])
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([])
def login(request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["username"],
password=serializer.validated_data["password"],
)
if user is None or user.is_disabled:
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
team = get_current_team(user)
token, _ = Token.objects.get_or_create(user=user)
return Response(auth_payload(user, team, token))
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def logout(request):
Token.objects.filter(user=request.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def me(request):
team = get_current_team(request.user)
return Response(
{
"user": UserSerializer(request.user).data,
"team": TeamSerializer(team).data,
}
)
def normalize_member_role(role):
if role == "super":
return TeamMember.Role.OWNER
if role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN, TeamMember.Role.MEMBER, TeamMember.Role.VIEWER}:
return role
return TeamMember.Role.MEMBER
def can_manage_team(user, team):
member = TeamMember.objects.filter(team=team, user=user, status=TeamMember.Status.ACTIVE).first()
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_members(request):
team = get_current_team(request.user)
if request.method == "GET":
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
return Response(TeamMemberSerializer(members, many=True).data)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
username = str(request.data.get("username") or "").strip()
password = str(request.data.get("password") or "").strip()
if not username:
return Response({"username": ["This field is required."]}, status=status.HTTP_400_BAD_REQUEST)
if len(password) < 8:
return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"username": ["username already exists"]}, status=status.HTTP_400_BAD_REQUEST)
email = str(request.data.get("email") or "").strip() or f"{username}@airshelf.local"
role = normalize_member_role(request.data.get("role"))
if role == TeamMember.Role.OWNER:
role = TeamMember.Role.ADMIN
with transaction.atomic():
user = User.objects.create_user(username=username, password=password, email=email)
user.first_name = str(request.data.get("name") or "").strip()
user.save(update_fields=["first_name"])
member = TeamMember.objects.create(
team=team,
user=user,
role=role,
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0,
)
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsAuthenticated])
def team_member_detail(request, member_id):
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first()
if member is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if member.user_id == team.owner_id:
return Response({"detail": "team owner cannot be changed"}, status=status.HTTP_400_BAD_REQUEST)
if request.method == "DELETE":
user = member.user
member.delete()
if not TeamMember.objects.filter(user=user).exists():
user.status = User.Status.DISABLED
user.save(update_fields=["status"])
Token.objects.filter(user=user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
role = request.data.get("role")
if role:
member.role = normalize_member_role(role)
if member.role == TeamMember.Role.OWNER:
member.role = TeamMember.Role.ADMIN
if "monthly_credit_limit" in request.data or "monthly" in request.data:
member.monthly_credit_limit = request.data.get("monthly_credit_limit", request.data.get("monthly")) or 0
name = str(request.data.get("name") or "").strip()
if name:
member.user.first_name = name
member.user.save(update_fields=["first_name"])
member.save(update_fields=["role", "monthly_credit_limit", "updated_at"])
return Response(TeamMemberSerializer(member).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def team_member_password(request, member_id):
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first()
if member is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if member.user_id == team.owner_id:
return Response({"detail": "team owner password cannot be reset here"}, status=status.HTTP_400_BAD_REQUEST)
password = str(request.data.get("password") or "").strip()
if len(password) < 8:
return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST)
member.user.set_password(password)
member.user.save(update_fields=["password"])
Token.objects.filter(user=member.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
+1
View File
@@ -0,0 +1 @@
+26
View File
@@ -0,0 +1,26 @@
from django.contrib import admin
from .models import AITask, ModelConfig, ModelProvider
@admin.register(ModelProvider)
class ModelProviderAdmin(admin.ModelAdmin):
list_display = ("name", "display_name", "status", "updated_at")
search_fields = ("name", "display_name")
list_filter = ("status",)
@admin.register(ModelConfig)
class ModelConfigAdmin(admin.ModelAdmin):
list_display = ("provider", "name", "capability", "unit_price", "status", "rate_limit_per_minute")
search_fields = ("provider__name", "name", "display_name")
list_filter = ("capability", "status")
@admin.register(AITask)
class AITaskAdmin(admin.ModelAdmin):
list_display = ("id", "team", "project", "task_type", "status", "model_config", "actual_cost", "updated_at")
search_fields = ("idempotency_key", "provider_task_id", "project__name", "team__name")
list_filter = ("task_type", "status", "model_config__capability")
readonly_fields = ("request_payload", "response_payload")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AiConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.ai"
+73
View File
@@ -0,0 +1,73 @@
VOLCANO_PROVIDER = {
"name": "volcengine",
"display_name": "火山引擎(豆包)",
"base_url": "https://ark.cn-beijing.volces.com/api/v3",
}
VOLCANO_MODELS = [
{
"display_name": "Doubao-Seed-2.0-Pro",
"name": "doubao-seed-2-0-pro-260215",
"capability": "text",
"endpoint": "chat/completions",
"metadata": {"think": True, "source": "video-flow/data/vendor/volcengine.ts"},
},
{
"display_name": "Doubao-Seed-2.0-Lite",
"name": "doubao-seed-2-0-lite-260215",
"capability": "text",
"endpoint": "chat/completions",
"metadata": {"think": True, "source": "video-flow/data/vendor/volcengine.ts"},
},
{
"display_name": "Seedream-5.0",
"name": "doubao-seedream-5-0-260128",
"capability": "image",
"endpoint": "images/generations",
"metadata": {
"modes": ["text", "singleImage", "multiReference"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedream-4.5",
"name": "doubao-seedream-4-5-251128",
"capability": "image",
"endpoint": "images/generations",
"metadata": {
"modes": ["text", "singleImage", "multiReference"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedance-2.0",
"name": "doubao-seedance-2-0-260128",
"capability": "video",
"endpoint": "contents/generations/tasks",
"metadata": {
"audio": "optional",
"modes": ["text", "startFrameOptional", "imageReference:9", "videoReference:3", "audioReference:3"],
"durations": list(range(4, 16)),
"resolutions": ["480p", "720p"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
{
"display_name": "Seedance-1.5-Pro",
"name": "doubao-seedance-1-5-pro-251215",
"capability": "video",
"endpoint": "contents/generations/tasks",
"metadata": {
"audio": "optional",
"modes": ["text", "startFrameOptional"],
"durations": list(range(4, 13)),
"resolutions": ["480p", "720p", "1080p"],
"watermark": False,
"source": "video-flow/data/vendor/volcengine.ts",
},
},
]
@@ -0,0 +1,171 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
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="ModelConfig",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
("display_name", models.CharField(max_length=128)),
(
"capability",
models.CharField(
choices=[
("text", "Text"),
("image", "Image"),
("video", "Video"),
("vision", "Vision"),
("export", "Export"),
],
max_length=32,
),
),
("endpoint", models.CharField(blank=True, max_length=255)),
(
"unit_price",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("rate_limit_per_minute", models.PositiveIntegerField(default=60)),
("metadata", models.JSONField(blank=True, default=dict)),
],
),
migrations.CreateModel(
name="ModelProvider",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=64, unique=True)),
("display_name", models.CharField(max_length=128)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("base_url", models.URLField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AITask",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"task_type",
models.CharField(
choices=[
("script_generation", "Script Generation"),
("script_optimization", "Script Optimization"),
("product_image", "Product Image"),
("person_image", "Person Image"),
("scene_image", "Scene Image"),
("storyboard", "Storyboard"),
("video_segment", "Video Segment"),
("export", "Export"),
],
max_length=48,
),
),
(
"status",
models.CharField(
choices=[
("created", "Created"),
("reserved", "Reserved"),
("submitted", "Submitted"),
("polling", "Polling"),
("postprocessing", "Postprocessing"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("compensating", "Compensating"),
("cancelled", "Cancelled"),
],
default="created",
max_length=32,
),
),
("idempotency_key", models.CharField(max_length=128, unique=True)),
("provider_task_id", models.CharField(blank=True, max_length=255)),
("request_payload", models.JSONField(blank=True, default=dict)),
("response_payload", models.JSONField(blank=True, default=dict)),
(
"estimated_cost",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
(
"actual_cost",
models.DecimalField(decimal_places=4, default=0, max_digits=12),
),
("error_code", models.CharField(blank=True, max_length=64)),
("error_message", models.TextField(blank=True)),
("submitted_at", models.DateTimeField(blank=True, null=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
],
),
]
@@ -0,0 +1,78 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="aitask",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="ai_tasks",
to="projects.project",
),
),
migrations.AddField(
model_name="aitask",
name="team",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
migrations.AddField(
model_name="aitask",
name="model_config",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="tasks",
to="ai.modelconfig",
),
),
migrations.AddField(
model_name="modelconfig",
name="provider",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="models",
to="ai.modelprovider",
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["team", "status"], name="ai_aitask_team_id_710ece_idx"
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["project", "task_type"], name="ai_aitask_project_f2850d_idx"
),
),
migrations.AddIndex(
model_name="aitask",
index=models.Index(
fields=["provider_task_id"], name="ai_aitask_provide_67beef_idx"
),
),
migrations.AlterUniqueTogether(
name="modelconfig",
unique_together={("provider", "name", "capability")},
),
]
@@ -0,0 +1 @@
+102
View File
@@ -0,0 +1,102 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class ModelProvider(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
name = models.CharField(max_length=64, unique=True)
display_name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
base_url = models.URLField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
def __str__(self) -> str:
return self.display_name
class ModelConfig(TimeStampedModel):
class Capability(models.TextChoices):
TEXT = "text", "Text"
IMAGE = "image", "Image"
VIDEO = "video", "Video"
VISION = "vision", "Vision"
EXPORT = "export", "Export"
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
provider = models.ForeignKey(ModelProvider, on_delete=models.CASCADE, related_name="models")
name = models.CharField(max_length=128)
display_name = models.CharField(max_length=128)
capability = models.CharField(max_length=32, choices=Capability.choices)
endpoint = models.CharField(max_length=255, blank=True)
unit_price = models.DecimalField(max_digits=12, decimal_places=4, default=0)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
rate_limit_per_minute = models.PositiveIntegerField(default=60)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
unique_together = [("provider", "name", "capability")]
def __str__(self) -> str:
return f"{self.provider.name}:{self.name}:{self.capability}"
class AITask(TeamOwnedModel):
class Type(models.TextChoices):
SCRIPT_GENERATION = "script_generation", "Script Generation"
SCRIPT_OPTIMIZATION = "script_optimization", "Script Optimization"
PRODUCT_IMAGE = "product_image", "Product Image"
PERSON_IMAGE = "person_image", "Person Image"
SCENE_IMAGE = "scene_image", "Scene Image"
STORYBOARD = "storyboard", "Storyboard"
VIDEO_SEGMENT = "video_segment", "Video Segment"
EXPORT = "export", "Export"
class Status(models.TextChoices):
CREATED = "created", "Created"
RESERVED = "reserved", "Reserved"
SUBMITTED = "submitted", "Submitted"
POLLING = "polling", "Polling"
POSTPROCESSING = "postprocessing", "Postprocessing"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
COMPENSATING = "compensating", "Compensating"
CANCELLED = "cancelled", "Cancelled"
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="ai_tasks",
)
task_type = models.CharField(max_length=48, choices=Type.choices)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.CREATED)
model_config = models.ForeignKey(ModelConfig, on_delete=models.PROTECT, related_name="tasks")
idempotency_key = models.CharField(max_length=128, unique=True)
provider_task_id = models.CharField(max_length=255, blank=True)
request_payload = models.JSONField(default=dict, blank=True)
response_payload = models.JSONField(default=dict, blank=True)
estimated_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
actual_cost = models.DecimalField(max_digits=12, decimal_places=4, default=0)
error_code = models.CharField(max_length=64, blank=True)
error_message = models.TextField(blank=True)
submitted_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["project", "task_type"]),
models.Index(fields=["provider_task_id"]),
]
def __str__(self) -> str:
return f"{self.task_type}:{self.status}:{self.id}"
@@ -0,0 +1,6 @@
from .base import AIProvider, AIProviderResult
from .volcano import VolcanoArkProvider
__all__ = ["AIProvider", "AIProviderResult", "VolcanoArkProvider"]
+19
View File
@@ -0,0 +1,19 @@
from dataclasses import dataclass, field
from typing import Any, Protocol
@dataclass(frozen=True)
class AIProviderResult:
provider_task_id: str = ""
status: str = "succeeded"
payload: dict[str, Any] = field(default_factory=dict)
asset_urls: list[str] = field(default_factory=list)
class AIProvider(Protocol):
def submit(self, payload: dict[str, Any]) -> AIProviderResult:
...
def poll(self, provider_task_id: str) -> AIProviderResult:
...
+173
View File
@@ -0,0 +1,173 @@
from dataclasses import dataclass
import base64
from io import BytesIO
from typing import Any
import requests
from django.conf import settings
from .base import AIProviderResult
@dataclass
class VolcanoArkProvider:
api_key: str | None = None
base_url: str | None = None
def __post_init__(self) -> None:
self.api_key = self.api_key or settings.VOLCANO.get("ark_api_key")
self.base_url = self.base_url or settings.VOLCANO.get("ark_base_url")
def submit(self, payload: dict[str, Any]) -> AIProviderResult:
# The exact endpoint is resolved by ModelConfig; this adapter keeps IO centralized.
endpoint = payload.get("endpoint")
if not endpoint:
raise ValueError("Volcano request payload requires endpoint")
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}"},
json=payload.get("body", {}),
timeout=60,
)
response.raise_for_status()
data = response.json()
return AIProviderResult(
provider_task_id=str(data.get("id") or data.get("task_id") or ""),
status=str(data.get("status") or "submitted"),
payload=data,
)
def chat_completion(self, *, model: str, messages: list[dict[str, str]], endpoint: str = "chat/completions") -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json={"model": model, "messages": messages},
timeout=120,
)
response.raise_for_status()
return response.json()
@staticmethod
def extract_text(data: dict[str, Any]) -> str:
choices = data.get("choices") or []
if choices:
message = choices[0].get("message") or {}
content = message.get("content")
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(str(item.get("text", "")) for item in content if isinstance(item, dict))
output = data.get("output")
if isinstance(output, str):
return output
raise ValueError("Volcano response does not contain text content")
def poll(self, provider_task_id: str) -> AIProviderResult:
if not provider_task_id:
raise ValueError("provider_task_id is required")
return AIProviderResult(provider_task_id=provider_task_id, status="polling", payload={})
def image_generation(
self,
*,
model: str,
prompt: str,
endpoint: str = "images/generations",
image: str | list[str] | None = None,
size: str = "2K",
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
body: dict[str, Any] = {
"model": model,
"prompt": prompt,
"response_format": "url",
"watermark": False,
"size": size,
"sequential_image_generation": "disabled",
}
if image:
body["image"] = image
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=180,
)
response.raise_for_status()
return response.json()
def create_video_task(
self,
*,
model: str,
endpoint: str,
prompt: str,
ratio: str = "9:16",
duration: int = 15,
resolution: str = "720p",
reference_images: list[str] | None = None,
) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
content: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
for image_url in reference_images or []:
content.append({"type": "image_url", "image_url": {"url": image_url}, "role": "reference_image"})
body = {
"model": model,
"content": content,
"ratio": ratio,
"duration": duration,
"resolution": resolution,
"watermark": False,
"generate_audio": False,
}
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
json=body,
timeout=120,
)
response.raise_for_status()
return response.json()
def poll_video_task(self, *, endpoint: str, provider_task_id: str) -> dict[str, Any]:
if not self.api_key:
raise ValueError("VOLCANO_ARK_API_KEY is not configured")
response = requests.get(
f"{self.base_url.rstrip('/')}/{endpoint.rstrip('/')}/{provider_task_id}",
headers={"Authorization": f"Bearer {self.api_key}"},
timeout=60,
)
response.raise_for_status()
return response.json()
@staticmethod
def extract_first_media_url(data: dict[str, Any]) -> str:
items = data.get("data") or []
for item in items:
if item.get("url"):
return item["url"]
if item.get("b64_json"):
return item["b64_json"]
content = data.get("content") or {}
if content.get("video_url"):
return content["video_url"]
raise ValueError("Volcano response does not contain media url")
@staticmethod
def media_to_bytes(media: str) -> tuple[BytesIO, str]:
if media.startswith("http://") or media.startswith("https://"):
response = requests.get(media, timeout=180)
response.raise_for_status()
return BytesIO(response.content), response.headers.get("content-type", "application/octet-stream")
if "," in media and media.startswith("data:"):
header, raw = media.split(",", 1)
content_type = header.split(";")[0].replace("data:", "") or "application/octet-stream"
return BytesIO(base64.b64decode(raw)), content_type
return BytesIO(base64.b64decode(media)), "image/png"
+44
View File
@@ -0,0 +1,44 @@
from rest_framework import serializers
from .models import AITask, ModelConfig, ModelProvider
class ModelProviderSerializer(serializers.ModelSerializer):
class Meta:
model = ModelProvider
fields = ["id", "name", "display_name", "status", "base_url", "metadata"]
read_only_fields = fields
class ModelConfigSerializer(serializers.ModelSerializer):
provider = ModelProviderSerializer(read_only=True)
class Meta:
model = ModelConfig
fields = ["id", "provider", "name", "display_name", "capability", "endpoint", "unit_price", "status", "metadata"]
read_only_fields = fields
class AITaskSerializer(serializers.ModelSerializer):
model_config = ModelConfigSerializer(read_only=True)
class Meta:
model = AITask
fields = [
"id",
"project",
"task_type",
"status",
"model_config",
"provider_task_id",
"estimated_cost",
"actual_cost",
"error_code",
"error_message",
"submitted_at",
"completed_at",
"created_at",
"updated_at",
]
read_only_fields = fields
+421
View File
@@ -0,0 +1,421 @@
import uuid
from decimal import Decimal
from django.db import transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.providers import VolcanoArkProvider
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
from apps.projects.models import (
BaseAssetGroup,
ExportJob,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
VideoSegment,
VideoSegmentVersion,
)
def get_default_model(capability: str) -> ModelConfig:
return (
ModelConfig.objects.select_related("provider")
.filter(capability=capability, status=ModelConfig.Status.ACTIVE, provider__status="active")
.order_by("created_at")
.first()
)
def estimate_cost(model_config: ModelConfig) -> Decimal:
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[str] | None = None) -> list[dict[str, str]]:
product = project.product
selling_points = product.selling_points.all()
if selling_point_ids:
selling_points = selling_points.filter(id__in=selling_point_ids)
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
system = (
"你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本,"
"拆成 4 个 15 秒段落。每段包含旁白、画面描述、商品露出方式和转场建议。"
)
user = f"""
商品标题:{product.title}
品牌:{product.brand or "未填写"}
类目:{product.category or "未填写"}
目标人群:{product.target_audience or "未填写"}
商品描述:{product.description or "未填写"}
卖点:
{selling_text or "未选择卖点,请根据商品信息自行提炼。"}
用户补充需求:
{user_prompt or "生成一条结构完整、节奏清晰、适合投放的带货短视频脚本。"}
""".strip()
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def split_script_into_segments(content: str) -> list[str]:
blocks = [line.strip() for line in content.splitlines() if line.strip()]
if len(blocks) >= 4:
return blocks[:4]
if not content.strip():
return [""] * 4
return [content.strip()] + [""] * (4 - len(blocks or [content]))
@transaction.atomic
def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig, request_payload: dict) -> AITask:
cost = estimate_cost(model_config)
task = AITask.objects.create(
team=project.team,
created_by=user,
project=project,
task_type=task_type,
status=AITask.Status.CREATED,
model_config=model_config,
idempotency_key=f"{task_type}:{project.id}:{uuid.uuid4()}",
request_payload=request_payload,
estimated_cost=cost,
)
reserve_credit(team=project.team, user=user, task=task, amount=cost)
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
return task
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None) -> ScriptVersion:
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
messages = build_script_prompt(project=project, user_prompt=user_prompt, selling_point_ids=selling_point_ids)
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages}
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.SCRIPT_GENERATION,
model_config=model_config,
request_payload=payload,
)
reservation = task.credit_reservation
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages)
content = provider.extract_text(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
script = ScriptVersion.objects.create(
project=project,
task=task,
title="AI 脚本",
content=content,
source="ai",
is_adopted=False,
)
for index, segment_text in enumerate(split_script_into_segments(content)):
ScriptSegment.objects.create(
script_version=script,
sort_order=index,
duration_seconds=15,
narration=segment_text,
visual_prompt=segment_text,
)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])
return script
except Exception as exc:
with transaction.atomic():
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
def _store_generated_media(*, team, user, project, task, media: str, name: str, category: str, asset_type: str) -> Asset:
fileobj, content_type = VolcanoArkProvider.media_to_bytes(media)
suffix = ".png"
if "video" in content_type:
suffix = ".mp4"
elif "jpeg" in content_type:
suffix = ".jpg"
elif "webp" in content_type:
suffix = ".webp"
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/projects/{project.id}/generated/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type=content_type)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=user,
name=name,
asset_type=asset_type,
source=Asset.Source.AI_GENERATED,
category=category,
origin_task=task,
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return asset
def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGroup:
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("no active image model configured")
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "kind": kind}
task = create_ai_task(
project=project,
user=user,
task_type={
BaseAssetGroup.Kind.PRODUCT: AITask.Type.PRODUCT_IMAGE,
BaseAssetGroup.Kind.PERSON: AITask.Type.PERSON_IMAGE,
BaseAssetGroup.Kind.SCENE: AITask.Type.SCENE_IMAGE,
}[kind],
model_config=model_config,
request_payload=payload,
)
reservation = task.credit_reservation
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.image_generation(model=model_config.name, endpoint=model_config.endpoint, prompt=prompt)
media = provider.extract_first_media_url(response)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
category = {
BaseAssetGroup.Kind.PRODUCT: Asset.Category.PRODUCT_IMAGE,
BaseAssetGroup.Kind.PERSON: Asset.Category.PERSON,
BaseAssetGroup.Kind.SCENE: Asset.Category.SCENE,
}[kind]
asset = _store_generated_media(
team=project.team,
user=user,
project=project,
task=task,
media=media,
name=f"{project.name}-{kind}",
category=category,
asset_type=Asset.Type.IMAGE,
)
group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt)
group.candidate_assets.add(asset)
group.adopted_asset = asset
group.save(update_fields=["adopted_asset", "updated_at"])
return group
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
def generate_storyboard(*, project, user, prompt: str = "") -> StoryboardVersion:
adopted_script = project.script_versions.filter(is_adopted=True).prefetch_related("segments").first()
if adopted_script is None:
raise ValueError("script must be adopted before generating storyboard")
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("no active image model configured")
storyboard = StoryboardVersion.objects.create(project=project, prompt=prompt)
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
for segment in adopted_script.segments.all():
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.STORYBOARD,
model_config=model_config,
request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "prompt": segment.visual_prompt},
)
reservation = task.credit_reservation
try:
response = provider.image_generation(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=f"{prompt}\n{segment.visual_prompt}".strip(),
)
media = provider.extract_first_media_url(response)
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
asset = _store_generated_media(
team=project.team,
user=user,
project=project,
task=task,
media=media,
name=f"{project.name}-storyboard-{segment.sort_order + 1}",
category=Asset.Category.SCENE,
asset_type=Asset.Type.IMAGE,
)
StoryboardFrame.objects.create(
storyboard=storyboard,
script_segment=segment,
asset=asset,
sort_order=segment.sort_order,
prompt=segment.visual_prompt,
)
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
storyboard.is_adopted = True
storyboard.save(update_fields=["is_adopted", "updated_at"])
return storyboard
def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
model_config = get_default_model(ModelConfig.Capability.VIDEO)
if model_config is None:
raise ValueError("no active video model configured")
project = video_segment.project
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.VIDEO_SEGMENT,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"prompt": prompt,
"duration": video_segment.target_duration_seconds,
"ratio": "9:16",
"video_segment_id": str(video_segment.id),
},
)
try:
provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None)
response = provider.create_video_task(
model=model_config.name,
endpoint=model_config.endpoint,
prompt=prompt,
duration=video_segment.target_duration_seconds,
ratio="9:16",
resolution="720p",
)
task.provider_task_id = str(response.get("id") or response.get("task_id") or "")
task.response_payload = response
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["provider_task_id", "response_payload", "status", "submitted_at", "updated_at"])
video_segment.status = VideoSegment.Status.RUNNING
video_segment.save(update_fields=["status", "updated_at"])
return None
except Exception as exc:
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=task.credit_reservation, reason=str(exc))
video_segment.status = VideoSegment.Status.FAILED
video_segment.error_message = str(exc)
video_segment.save(update_fields=["status", "error_message", "updated_at"])
raise
def poll_video_segment(*, video_segment: VideoSegment, user) -> VideoSegmentVersion | None:
task = video_segment.versions.order_by("-created_at").first()
ai_task = None
if task:
ai_task = task.task
if ai_task is None:
ai_task = video_segment.project.ai_tasks.filter(
task_type=AITask.Type.VIDEO_SEGMENT,
request_payload__video_segment_id=str(video_segment.id),
status__in=[AITask.Status.SUBMITTED, AITask.Status.POLLING],
).order_by("-created_at").first()
if ai_task is None:
raise ValueError("no active video generation task")
provider = VolcanoArkProvider(base_url=ai_task.model_config.provider.base_url or None)
response = provider.poll_video_task(endpoint=ai_task.model_config.endpoint, provider_task_id=ai_task.provider_task_id)
remote_status = response.get("status")
if remote_status in {"queued", "running", "processing"}:
ai_task.status = AITask.Status.POLLING
ai_task.response_payload = response
ai_task.save(update_fields=["status", "response_payload", "updated_at"])
return None
if remote_status in {"failed", "expired", "cancelled"}:
ai_task.status = AITask.Status.FAILED
ai_task.response_payload = response
ai_task.error_message = response.get("error", {}).get("message", "video generation failed")
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "error_message", "completed_at", "updated_at"])
release_credit(reservation=ai_task.credit_reservation, reason=ai_task.error_message)
video_segment.status = VideoSegment.Status.FAILED
video_segment.error_message = ai_task.error_message
video_segment.save(update_fields=["status", "error_message", "updated_at"])
return None
media = provider.extract_first_media_url(response)
asset = _store_generated_media(
team=video_segment.project.team,
user=user,
project=video_segment.project,
task=ai_task,
media=media,
name=f"{video_segment.project.name}-segment-{video_segment.sort_order + 1}",
category=Asset.Category.VIDEO_CLIP,
asset_type=Asset.Type.VIDEO,
)
ai_task.status = AITask.Status.SUCCEEDED
ai_task.response_payload = response
ai_task.actual_cost = ai_task.estimated_cost
ai_task.completed_at = timezone.now()
ai_task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=ai_task.credit_reservation, actual_amount=ai_task.actual_cost)
version = VideoSegmentVersion.objects.create(
video_segment=video_segment,
task=ai_task,
asset=asset,
prompt=ai_task.request_payload.get("prompt", ""),
is_adopted=True,
)
video_segment.adopted_version = version
video_segment.status = VideoSegment.Status.SUCCEEDED
video_segment.error_message = ""
video_segment.save(update_fields=["adopted_version", "status", "error_message", "updated_at"])
return version
def create_export_job(*, timeline, user) -> ExportJob:
return ExportJob.objects.create(timeline=timeline, status=ExportJob.Status.QUEUED)
+12
View File
@@ -0,0 +1,12 @@
from airshelf.celery import app
@app.task(bind=True, max_retries=3)
def submit_ai_task(self, task_id: str) -> str:
return task_id
@app.task(bind=True, max_retries=5)
def poll_ai_task(self, task_id: str) -> str:
return task_id
+9
View File
@@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from .views import AITaskViewSet, ModelConfigViewSet
router = DefaultRouter()
router.register("tasks", AITaskViewSet, basename="ai-task")
router.register("models", ModelConfigViewSet, basename="model-config")
urlpatterns = router.urls
+21
View File
@@ -0,0 +1,21 @@
from rest_framework.viewsets import ReadOnlyModelViewSet
from apps.common.api import TeamScopedViewSetMixin
from .models import AITask, ModelConfig
from .serializers import AITaskSerializer, ModelConfigSerializer
class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
queryset = AITask.objects.select_related("team", "project", "model_config", "model_config__provider").all()
serializer_class = AITaskSerializer
search_fields = ["idempotency_key", "provider_task_id", "project__name"]
ordering_fields = ["created_at", "updated_at", "completed_at"]
class ModelConfigViewSet(ReadOnlyModelViewSet):
queryset = ModelConfig.objects.select_related("provider").filter(status=ModelConfig.Status.ACTIVE)
serializer_class = ModelConfigSerializer
search_fields = ["name", "display_name", "capability"]
ordering_fields = ["created_at", "display_name"]
+1
View File
@@ -0,0 +1 @@
+22
View File
@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import Asset, AssetFile, AssetTag, AssetTagging, AssetUsage
class AssetFileInline(admin.TabularInline):
model = AssetFile
extra = 0
@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
list_display = ("name", "team", "asset_type", "source", "category", "is_deleted", "created_at")
search_fields = ("name", "team__name")
list_filter = ("asset_type", "source", "category", "is_deleted")
inlines = [AssetFileInline]
admin.site.register(AssetTag)
admin.site.register(AssetTagging)
admin.site.register(AssetUsage)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AssetsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.assets"
@@ -0,0 +1,232 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
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 = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Asset",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
(
"asset_type",
models.CharField(
choices=[
("image", "Image"),
("video", "Video"),
("audio", "Audio"),
("subtitle", "Subtitle"),
("document", "Document"),
],
max_length=24,
),
),
(
"source",
models.CharField(
choices=[
("upload", "Upload"),
("ai_generated", "AI Generated"),
("exported", "Exported"),
("system", "System"),
],
max_length=32,
),
),
(
"category",
models.CharField(
choices=[
("person", "Person"),
("scene", "Scene"),
("product_image", "Product Image"),
("video_clip", "Video Clip"),
("final_video", "Final Video"),
("upload", "Upload"),
("uncategorized", "Uncategorized"),
],
default="uncategorized",
max_length=32,
),
),
("description", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_deleted", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"origin_task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="generated_assets",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AssetFile",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_key", models.CharField(max_length=512)),
("bucket", models.CharField(max_length=128)),
("content_type", models.CharField(blank=True, max_length=128)),
("size_bytes", models.BigIntegerField(default=0)),
("checksum", models.CharField(blank=True, max_length=128)),
("width", models.PositiveIntegerField(blank=True, null=True)),
("height", models.PositiveIntegerField(blank=True, null=True)),
("duration_ms", models.PositiveIntegerField(blank=True, null=True)),
("preview_url", models.URLField(blank=True)),
("is_primary", models.BooleanField(default=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="assets.asset",
),
),
],
),
migrations.CreateModel(
name="AssetTag",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=64)),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="asset_tags",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="AssetTagging",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="taggings",
to="assets.asset",
),
),
(
"tag",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="taggings",
to="assets.assettag",
),
),
],
),
migrations.CreateModel(
name="AssetUsage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("usage_type", models.CharField(max_length=64)),
("context", models.JSONField(blank=True, default=dict)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="usages",
to="assets.asset",
),
),
],
options={
"abstract": False,
},
),
]
@@ -0,0 +1,42 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("assets", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="assetusage",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="asset_usages",
to="projects.project",
),
),
migrations.AddIndex(
model_name="assetfile",
index=models.Index(
fields=["bucket", "object_key"], name="assets_asse_bucket_94a505_idx"
),
),
migrations.AlterUniqueTogether(
name="assettag",
unique_together={("team", "name")},
),
migrations.AlterUniqueTogether(
name="assettagging",
unique_together={("asset", "tag")},
),
]
@@ -0,0 +1 @@
+100
View File
@@ -0,0 +1,100 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class Asset(TeamOwnedModel):
class Type(models.TextChoices):
IMAGE = "image", "Image"
VIDEO = "video", "Video"
AUDIO = "audio", "Audio"
SUBTITLE = "subtitle", "Subtitle"
DOCUMENT = "document", "Document"
class Source(models.TextChoices):
UPLOAD = "upload", "Upload"
AI_GENERATED = "ai_generated", "AI Generated"
EXPORTED = "exported", "Exported"
SYSTEM = "system", "System"
class Category(models.TextChoices):
PERSON = "person", "Person"
SCENE = "scene", "Scene"
PRODUCT_IMAGE = "product_image", "Product Image"
VIDEO_CLIP = "video_clip", "Video Clip"
FINAL_VIDEO = "final_video", "Final Video"
UPLOAD = "upload", "Upload"
UNCATEGORIZED = "uncategorized", "Uncategorized"
name = models.CharField(max_length=255)
asset_type = models.CharField(max_length=24, choices=Type.choices)
source = models.CharField(max_length=32, choices=Source.choices)
category = models.CharField(max_length=32, choices=Category.choices, default=Category.UNCATEGORIZED)
description = models.TextField(blank=True)
origin_task = models.ForeignKey(
"ai.AITask",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="generated_assets",
)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
def __str__(self) -> str:
return self.name
class AssetFile(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="files")
object_key = models.CharField(max_length=512)
bucket = models.CharField(max_length=128)
content_type = models.CharField(max_length=128, blank=True)
size_bytes = models.BigIntegerField(default=0)
checksum = models.CharField(max_length=128, blank=True)
width = models.PositiveIntegerField(null=True, blank=True)
height = models.PositiveIntegerField(null=True, blank=True)
duration_ms = models.PositiveIntegerField(null=True, blank=True)
preview_url = models.URLField(blank=True)
is_primary = models.BooleanField(default=True)
class Meta:
indexes = [
models.Index(fields=["bucket", "object_key"]),
]
def __str__(self) -> str:
return self.object_key
class AssetTag(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="asset_tags")
name = models.CharField(max_length=64)
class Meta:
unique_together = [("team", "name")]
def __str__(self) -> str:
return self.name
class AssetTagging(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="taggings")
tag = models.ForeignKey(AssetTag, on_delete=models.CASCADE, related_name="taggings")
class Meta:
unique_together = [("asset", "tag")]
class AssetUsage(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="usages")
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="asset_usages",
)
usage_type = models.CharField(max_length=64)
context = models.JSONField(default=dict, blank=True)
+51
View File
@@ -0,0 +1,51 @@
from rest_framework import serializers
from .models import Asset, AssetFile
class AssetFileSerializer(serializers.ModelSerializer):
class Meta:
model = AssetFile
fields = [
"id",
"object_key",
"bucket",
"content_type",
"size_bytes",
"width",
"height",
"duration_ms",
"preview_url",
"is_primary",
]
read_only_fields = fields
class AssetSerializer(serializers.ModelSerializer):
files = AssetFileSerializer(many=True, read_only=True)
class Meta:
model = Asset
fields = [
"id",
"name",
"asset_type",
"source",
"category",
"description",
"metadata",
"is_deleted",
"files",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
class AssetUploadSerializer(serializers.Serializer):
file = serializers.FileField()
name = serializers.CharField(max_length=255, required=False, allow_blank=True)
asset_type = serializers.ChoiceField(choices=Asset.Type.choices)
category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD)
description = serializers.CharField(required=False, allow_blank=True)
+52
View File
@@ -0,0 +1,52 @@
from dataclasses import dataclass
from typing import BinaryIO
import boto3
from botocore.config import Config
from django.conf import settings
@dataclass(frozen=True)
class StoredObject:
bucket: str
object_key: str
content_type: str
size_bytes: int
class TosStorage:
def __init__(self) -> None:
tos = settings.TOS
self.bucket = tos["bucket"]
self.client = boto3.client(
"s3",
endpoint_url=tos["endpoint"],
aws_access_key_id=tos["access_key_id"],
aws_secret_access_key=tos["secret_access_key"],
region_name="cn-shanghai",
config=Config(s3={"addressing_style": "virtual"}),
)
def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject:
fileobj.seek(0, 2)
size = fileobj.tell()
fileobj.seek(0)
self.client.upload_fileobj(
fileobj,
self.bucket,
object_key,
ExtraArgs={"ContentType": content_type},
)
return StoredObject(
bucket=self.bucket,
object_key=object_key,
content_type=content_type,
size_bytes=size,
)
def presigned_get_url(self, *, object_key: str, expires_in: int = 3600) -> str:
return self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": object_key},
ExpiresIn=expires_in,
)
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import AssetUploadView, AssetViewSet
router = DefaultRouter()
router.register("", AssetViewSet, basename="asset")
urlpatterns = [
path("upload/", AssetUploadView.as_view(), name="asset-upload"),
] + router.urls
+61
View File
@@ -0,0 +1,61 @@
from pathlib import Path
import uuid
from django.db import transaction
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import Asset, AssetFile
from .serializers import AssetSerializer, AssetUploadSerializer
from .storage import TosStorage
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Asset.objects.prefetch_related("files").all()
serializer_class = AssetSerializer
search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"]
class AssetUploadView(APIView):
parser_classes = [MultiPartParser, FormParser]
@transaction.atomic
def post(self, request):
serializer = AssetUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
team = get_current_team(request.user)
upload = serializer.validated_data["file"]
suffix = Path(upload.name).suffix.lower()
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "application/octet-stream",
)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=serializer.validated_data.get("name") or upload.name,
asset_type=serializer.validated_data["asset_type"],
source=Asset.Source.UPLOAD,
category=serializer.validated_data["category"],
description=serializer.validated_data.get("description", ""),
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)
+1
View File
@@ -0,0 +1 @@
+21
View File
@@ -0,0 +1,21 @@
from django.contrib import admin
from .models import CreditAccount, CreditLedger, CreditReservation, QuotaPolicy
@admin.register(CreditAccount)
class CreditAccountAdmin(admin.ModelAdmin):
list_display = ("team", "balance", "reserved_balance", "currency", "updated_at")
search_fields = ("team__name",)
@admin.register(CreditLedger)
class CreditLedgerAdmin(admin.ModelAdmin):
list_display = ("team", "user", "project", "task", "ledger_type", "amount", "balance_after", "created_at")
search_fields = ("team__name", "user__username", "project__name", "task__idempotency_key")
list_filter = ("ledger_type",)
admin.site.register(CreditReservation)
admin.site.register(QuotaPolicy)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class BillingConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.billing"
@@ -0,0 +1,277 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
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 = [
("accounts", "0001_initial"),
("ai", "0002_initial"),
("projects", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="CreditAccount",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"balance",
models.DecimalField(decimal_places=4, default=0, max_digits=14),
),
(
"reserved_balance",
models.DecimalField(decimal_places=4, default=0, max_digits=14),
),
("currency", models.CharField(default="CNY", max_length=16)),
(
"team",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_account",
to="accounts.team",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="CreditReservation",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("amount", models.DecimalField(decimal_places=4, max_digits=14)),
(
"status",
models.CharField(
choices=[
("active", "Active"),
("released", "Released"),
("charged", "Charged"),
("cancelled", "Cancelled"),
],
default="active",
max_length=32,
),
),
("expires_at", models.DateTimeField(blank=True, null=True)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_reservations",
to="projects.project",
),
),
(
"task",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_reservation",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_reservations",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_reservations",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="QuotaPolicy",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"monthly_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
(
"project_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
(
"per_task_limit",
models.DecimalField(
blank=True, decimal_places=4, max_digits=14, null=True
),
),
("is_active", models.BooleanField(default=True)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to="projects.project",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="quota_policies",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="CreditLedger",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"ledger_type",
models.CharField(
choices=[
("recharge", "Recharge"),
("reserve", "Reserve"),
("release", "Release"),
("charge", "Charge"),
("adjustment", "Adjustment"),
("refund", "Refund"),
],
max_length=32,
),
),
("amount", models.DecimalField(decimal_places=4, max_digits=14)),
("balance_after", models.DecimalField(decimal_places=4, max_digits=14)),
("reason", models.CharField(blank=True, max_length=255)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="credit_ledgers",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="credit_ledgers",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"indexes": [
models.Index(
fields=["team", "ledger_type"],
name="billing_cre_team_id_e0f18f_idx",
),
models.Index(
fields=["project", "task"],
name="billing_cre_project_a79834_idx",
),
],
},
),
]
@@ -0,0 +1 @@
+84
View File
@@ -0,0 +1,84 @@
from django.db import models
from apps.common.models import TimeStampedModel
class CreditAccount(TimeStampedModel):
team = models.OneToOneField("accounts.Team", on_delete=models.CASCADE, related_name="credit_account")
balance = models.DecimalField(max_digits=14, decimal_places=4, default=0)
reserved_balance = models.DecimalField(max_digits=14, decimal_places=4, default=0)
currency = models.CharField(max_length=16, default="CNY")
def __str__(self) -> str:
return f"{self.team} / {self.balance}"
class CreditLedger(TimeStampedModel):
class Type(models.TextChoices):
RECHARGE = "recharge", "Recharge"
RESERVE = "reserve", "Reserve"
RELEASE = "release", "Release"
CHARGE = "charge", "Charge"
ADJUSTMENT = "adjustment", "Adjustment"
REFUND = "refund", "Refund"
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="credit_ledgers")
user = models.ForeignKey("accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_ledgers")
project = models.ForeignKey(
"projects.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="credit_ledgers",
)
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_ledgers")
ledger_type = models.CharField(max_length=32, choices=Type.choices)
amount = models.DecimalField(max_digits=14, decimal_places=4)
balance_after = models.DecimalField(max_digits=14, decimal_places=4)
reason = models.CharField(max_length=255, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "ledger_type"]),
models.Index(fields=["project", "task"]),
]
class CreditReservation(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
RELEASED = "released", "Released"
CHARGED = "charged", "Charged"
CANCELLED = "cancelled", "Cancelled"
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="credit_reservations")
user = models.ForeignKey("accounts.User", on_delete=models.SET_NULL, null=True, blank=True, related_name="credit_reservations")
project = models.ForeignKey(
"projects.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="credit_reservations",
)
task = models.OneToOneField("ai.AITask", on_delete=models.CASCADE, related_name="credit_reservation")
amount = models.DecimalField(max_digits=14, decimal_places=4)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.ACTIVE)
expires_at = models.DateTimeField(null=True, blank=True)
class QuotaPolicy(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="quota_policies")
user = models.ForeignKey("accounts.User", on_delete=models.CASCADE, null=True, blank=True, related_name="quota_policies")
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="quota_policies",
)
monthly_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
project_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
per_task_limit = models.DecimalField(max_digits=14, decimal_places=4, null=True, blank=True)
is_active = models.BooleanField(default=True)
+43
View File
@@ -0,0 +1,43 @@
from rest_framework import serializers
from .models import CreditAccount, CreditLedger, CreditReservation, QuotaPolicy
class CreditAccountSerializer(serializers.ModelSerializer):
class Meta:
model = CreditAccount
fields = ["id", "balance", "reserved_balance", "currency", "updated_at"]
read_only_fields = fields
class CreditLedgerSerializer(serializers.ModelSerializer):
class Meta:
model = CreditLedger
fields = [
"id",
"user",
"project",
"task",
"ledger_type",
"amount",
"balance_after",
"reason",
"metadata",
"created_at",
]
read_only_fields = fields
class CreditReservationSerializer(serializers.ModelSerializer):
class Meta:
model = CreditReservation
fields = ["id", "user", "project", "task", "amount", "status", "expires_at", "created_at"]
read_only_fields = fields
class QuotaPolicySerializer(serializers.ModelSerializer):
class Meta:
model = QuotaPolicy
fields = ["id", "user", "project", "monthly_limit", "project_limit", "per_task_limit", "is_active"]
read_only_fields = ["id"]
@@ -0,0 +1 @@
@@ -0,0 +1,93 @@
from decimal import Decimal
from django.db import transaction
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
@transaction.atomic
def reserve_credit(*, team, user, task, amount: Decimal) -> CreditReservation:
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
available = account.balance - account.reserved_balance
if available < amount:
raise ValueError("insufficient credit")
account.reserved_balance += amount
account.save(update_fields=["reserved_balance", "updated_at"])
reservation = CreditReservation.objects.create(
team=team,
user=user,
project=task.project,
task=task,
amount=amount,
)
CreditLedger.objects.create(
team=team,
user=user,
project=task.project,
task=task,
ledger_type=CreditLedger.Type.RESERVE,
amount=amount,
balance_after=account.balance,
reason="reserve ai task credit",
)
return reservation
@transaction.atomic
def release_credit(*, reservation: CreditReservation, reason: str = "") -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
if reservation.status != CreditReservation.Status.ACTIVE:
return
account.reserved_balance -= reservation.amount
account.save(update_fields=["reserved_balance", "updated_at"])
reservation.status = CreditReservation.Status.RELEASED
reservation.save(update_fields=["status", "updated_at"])
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount,
balance_after=account.balance,
reason=reason or "release reserved credit",
)
@transaction.atomic
def charge_reserved_credit(*, reservation: CreditReservation, actual_amount: Decimal) -> None:
account = CreditAccount.objects.select_for_update().get(team=reservation.team)
if reservation.status != CreditReservation.Status.ACTIVE:
raise ValueError("reservation is not active")
if actual_amount > reservation.amount:
raise ValueError("actual amount exceeds reserved amount")
account.balance -= actual_amount
account.reserved_balance -= reservation.amount
account.save(update_fields=["balance", "reserved_balance", "updated_at"])
reservation.status = CreditReservation.Status.CHARGED
reservation.save(update_fields=["status", "updated_at"])
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.CHARGE,
amount=actual_amount,
balance_after=account.balance,
reason="charge ai task credit",
)
if reservation.amount > actual_amount:
CreditLedger.objects.create(
team=reservation.team,
user=reservation.user,
project=reservation.project,
task=reservation.task,
ledger_type=CreditLedger.Type.RELEASE,
amount=reservation.amount - actual_amount,
balance_after=account.balance,
reason="release unused reserved credit",
)
+60
View File
@@ -0,0 +1,60 @@
from decimal import Decimal
from django.test import TestCase
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import AITask, ModelConfig, ModelProvider
from apps.billing.models import CreditAccount, CreditLedger, CreditReservation
from apps.billing.services.ledger import charge_reserved_credit, release_credit, reserve_credit
class CreditLedgerTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="owner", password="pass")
self.team = Team.objects.create(name="Billing Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.account = CreditAccount.objects.create(team=self.team, balance=Decimal("100.0000"))
self.provider = ModelProvider.objects.create(name="volcengine", display_name="Volcano")
self.model = ModelConfig.objects.create(
provider=self.provider,
name="doubao-seed-2-0-pro-260215",
display_name="Doubao",
capability=ModelConfig.Capability.TEXT,
)
self.task = AITask.objects.create(
team=self.team,
created_by=self.user,
task_type=AITask.Type.SCRIPT_GENERATION,
model_config=self.model,
idempotency_key="billing-test-task",
estimated_cost=Decimal("10.0000"),
)
def test_reserve_and_charge_credit(self):
reservation = reserve_credit(team=self.team, user=self.user, task=self.task, amount=Decimal("10.0000"))
self.account.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.ACTIVE)
self.assertEqual(self.account.balance, Decimal("100.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("10.0000"))
charge_reserved_credit(reservation=reservation, actual_amount=Decimal("8.0000"))
self.account.refresh_from_db()
reservation.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.CHARGED)
self.assertEqual(self.account.balance, Decimal("92.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(team=self.team).count(), 3)
def test_release_reserved_credit(self):
reservation = reserve_credit(team=self.team, user=self.user, task=self.task, amount=Decimal("10.0000"))
release_credit(reservation=reservation, reason="model failed")
self.account.refresh_from_db()
reservation.refresh_from_db()
self.assertEqual(reservation.status, CreditReservation.Status.RELEASED)
self.assertEqual(self.account.balance, Decimal("100.0000"))
self.assertEqual(self.account.reserved_balance, Decimal("0.0000"))
self.assertEqual(CreditLedger.objects.filter(ledger_type=CreditLedger.Type.RELEASE).count(), 1)
+9
View File
@@ -0,0 +1,9 @@
from django.urls import path
from .views import ledgers, recharge, summary
urlpatterns = [
path("summary/", summary, name="billing-summary"),
path("ledgers/", ledgers, name="billing-ledgers"),
path("recharge/", recharge, name="billing-recharge"),
]
+80
View File
@@ -0,0 +1,80 @@
from decimal import Decimal, InvalidOperation
from django.db import transaction
from django.db.models import Sum
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import CreditAccount, CreditLedger
from .serializers import CreditAccountSerializer, CreditLedgerSerializer
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def summary(request):
team = get_current_team(request.user)
account, _ = CreditAccount.objects.get_or_create(team=team)
charged = CreditLedger.objects.filter(team=team, ledger_type=CreditLedger.Type.CHARGE).aggregate(
total=Sum("amount")
)["total"] or 0
return Response(
{
"account": CreditAccountSerializer(account).data,
"charged_total": charged,
}
)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def ledgers(request):
team = get_current_team(request.user)
queryset = CreditLedger.objects.filter(team=team).select_related("user", "project", "task").order_by("-created_at")
project_id = request.query_params.get("project")
user_id = request.query_params.get("user")
if project_id:
queryset = queryset.filter(project_id=project_id)
if user_id:
queryset = queryset.filter(user_id=user_id)
return Response(CreditLedgerSerializer(queryset[:100], many=True).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def recharge(request):
team = get_current_team(request.user)
try:
amount = Decimal(str(request.data.get("amount", "0")))
bonus = Decimal(str(request.data.get("bonus", "0")))
except (InvalidOperation, TypeError):
return Response({"detail": "invalid amount"}, status=status.HTTP_400_BAD_REQUEST)
if amount <= 0:
return Response({"detail": "amount must be positive"}, status=status.HTTP_400_BAD_REQUEST)
if bonus < 0:
return Response({"detail": "bonus cannot be negative"}, status=status.HTTP_400_BAD_REQUEST)
channel = str(request.data.get("channel") or "manual")[:32]
credited = amount + bonus
with transaction.atomic():
account, _ = CreditAccount.objects.select_for_update().get_or_create(team=team)
account.balance += credited
account.save(update_fields=["balance", "updated_at"])
ledger = CreditLedger.objects.create(
team=team,
user=request.user,
ledger_type=CreditLedger.Type.RECHARGE,
amount=credited,
balance_after=account.balance,
reason="团队充值",
metadata={"channel": channel, "paid_amount": str(amount), "bonus": str(bonus)},
)
return Response(
{
"account": CreditAccountSerializer(account).data,
"ledger": CreditLedgerSerializer(ledger).data,
},
status=status.HTTP_201_CREATED,
)
+1
View File
@@ -0,0 +1 @@
+23
View File
@@ -0,0 +1,23 @@
from rest_framework.exceptions import PermissionDenied
def get_current_team(user):
membership = user.team_memberships.filter(status="active").select_related("team").first()
if not membership:
raise PermissionDenied("current user has no active team")
return membership.team
class TeamScopedViewSetMixin:
team_field = "team"
def get_team(self):
return get_current_team(self.request.user)
def get_queryset(self):
queryset = super().get_queryset()
return queryset.filter(**{self.team_field: self.get_team()})
def perform_create(self, serializer):
serializer.save(team=self.get_team(), created_by=self.request.user)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class CommonConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.common"
@@ -0,0 +1 @@
@@ -0,0 +1 @@
@@ -0,0 +1,36 @@
from django.core.management.base import BaseCommand
from apps.ai.catalog import VOLCANO_MODELS, VOLCANO_PROVIDER
from apps.ai.models import ModelConfig, ModelProvider
class Command(BaseCommand):
help = "Create or update default Volcano model provider and model configs."
def handle(self, *args, **options):
provider, _ = ModelProvider.objects.update_or_create(
name=VOLCANO_PROVIDER["name"],
defaults={
"display_name": VOLCANO_PROVIDER["display_name"],
"base_url": VOLCANO_PROVIDER["base_url"],
"status": ModelProvider.Status.ACTIVE,
},
)
count = 0
for item in VOLCANO_MODELS:
ModelConfig.objects.update_or_create(
provider=provider,
name=item["name"],
capability=item["capability"],
defaults={
"display_name": item["display_name"],
"endpoint": item["endpoint"],
"status": ModelConfig.Status.ACTIVE,
"metadata": item["metadata"],
},
)
count += 1
self.stdout.write(self.style.SUCCESS(f"Bootstrapped {count} Volcano model configs."))
@@ -0,0 +1 @@
+33
View File
@@ -0,0 +1,33 @@
import uuid
from django.db import models
class UUIDModel(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class Meta:
abstract = True
class TimeStampedModel(UUIDModel):
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
abstract = True
class TeamOwnedModel(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="%(class)s_set")
created_by = models.ForeignKey(
"accounts.User",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_%(class)s_set",
)
class Meta:
abstract = True
+6
View File
@@ -0,0 +1,6 @@
from django.http import JsonResponse
def health_check(request):
return JsonResponse({"status": "ok", "service": "airshelf-backend"})
+1
View File
@@ -0,0 +1 @@
+16
View File
@@ -0,0 +1,16 @@
from django.contrib import admin
from .models import Notification
admin.site.site_header = "AirShelf Ops"
admin.site.site_title = "AirShelf Ops"
admin.site.index_title = "Operations"
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = ("title", "team", "recipient", "notification_type", "priority", "is_read", "created_at")
list_filter = ("notification_type", "priority", "is_read", "archived_at")
search_fields = ("title", "brief", "body", "source", "dedupe_key")
readonly_fields = ("created_at", "updated_at", "read_at", "archived_at")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class OpsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.ops"
@@ -0,0 +1,105 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("accounts", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.CreateModel(
name="Notification",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"notification_type",
models.CharField(
choices=[("task", "Task"), ("team", "Team"), ("billing", "Billing"), ("system", "System")],
default="system",
max_length=24,
),
),
(
"priority",
models.CharField(
choices=[("ok", "OK"), ("warn", "Warn"), ("err", "Error"), ("info", "Info")],
default="info",
max_length=24,
),
),
("title", models.CharField(max_length=200)),
("brief", models.CharField(blank=True, max_length=300)),
("body", models.TextField(blank=True)),
("source", models.CharField(blank=True, max_length=120)),
("stage", models.CharField(blank=True, max_length=120)),
("owner_label", models.CharField(blank=True, max_length=120)),
("cost_label", models.CharField(blank=True, max_length=64)),
("related_url", models.CharField(blank=True, max_length=300)),
("dedupe_key", models.CharField(blank=True, max_length=160)),
("is_read", models.BooleanField(default=False)),
("read_at", models.DateTimeField(blank=True, null=True)),
("archived_at", models.DateTimeField(blank=True, null=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="notifications",
to="projects.project",
),
),
(
"recipient",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="notifications",
to="accounts.team",
),
),
],
options={
"ordering": ["-created_at"],
},
),
migrations.AddIndex(
model_name="notification",
index=models.Index(fields=["team", "recipient", "is_read", "-created_at"], name="ops_notific_team_id_17a7ca_idx"),
),
migrations.AddIndex(
model_name="notification",
index=models.Index(fields=["team", "archived_at", "-created_at"], name="ops_notific_team_id_691eaf_idx"),
),
migrations.AddIndex(
model_name="notification",
index=models.Index(fields=["team", "dedupe_key"], name="ops_notific_team_id_8acdf4_idx"),
),
migrations.AddConstraint(
model_name="notification",
constraint=models.UniqueConstraint(
condition=~models.Q(dedupe_key=""),
fields=("team", "dedupe_key"),
name="ops_notification_team_dedupe_key_unique",
),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.1.15 on 2026-06-01 09:15
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0001_initial"),
("ops", "0001_initial"),
("projects", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveConstraint(
model_name="notification",
name="ops_notification_team_dedupe_key_unique",
),
migrations.AlterField(
model_name="notification",
name="dedupe_key",
field=models.CharField(blank=True, max_length=160, null=True),
),
migrations.AddConstraint(
model_name="notification",
constraint=models.UniqueConstraint(
fields=("team", "dedupe_key"),
name="ops_notification_team_dedupe_key_unique",
),
),
]
@@ -0,0 +1 @@
+84
View File
@@ -0,0 +1,84 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
from apps.common.models import TimeStampedModel
class Notification(TimeStampedModel):
class Type(models.TextChoices):
TASK = "task", "Task"
TEAM = "team", "Team"
BILLING = "billing", "Billing"
SYSTEM = "system", "System"
class Priority(models.TextChoices):
OK = "ok", "OK"
WARN = "warn", "Warn"
ERR = "err", "Error"
INFO = "info", "Info"
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="notifications")
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="notifications",
)
project = models.ForeignKey(
"projects.Project",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="notifications",
)
notification_type = models.CharField(max_length=24, choices=Type.choices, default=Type.SYSTEM)
priority = models.CharField(max_length=24, choices=Priority.choices, default=Priority.INFO)
title = models.CharField(max_length=200)
brief = models.CharField(max_length=300, blank=True)
body = models.TextField(blank=True)
source = models.CharField(max_length=120, blank=True)
stage = models.CharField(max_length=120, blank=True)
owner_label = models.CharField(max_length=120, blank=True)
cost_label = models.CharField(max_length=64, blank=True)
related_url = models.CharField(max_length=300, blank=True)
dedupe_key = models.CharField(max_length=160, blank=True, null=True)
is_read = models.BooleanField(default=False)
read_at = models.DateTimeField(null=True, blank=True)
archived_at = models.DateTimeField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["team", "recipient", "is_read", "-created_at"]),
models.Index(fields=["team", "archived_at", "-created_at"]),
models.Index(fields=["team", "dedupe_key"]),
]
constraints = [
models.UniqueConstraint(
fields=["team", "dedupe_key"],
name="ops_notification_team_dedupe_key_unique",
)
]
def mark_read(self):
if not self.is_read:
self.is_read = True
self.read_at = timezone.now()
self.save(update_fields=["is_read", "read_at", "updated_at"])
def mark_unread(self):
if self.is_read or self.read_at:
self.is_read = False
self.read_at = None
self.save(update_fields=["is_read", "read_at", "updated_at"])
def archive(self):
if self.archived_at is None:
self.archived_at = timezone.now()
self.save(update_fields=["archived_at", "updated_at"])
def __str__(self) -> str:
return self.title
+47
View File
@@ -0,0 +1,47 @@
from rest_framework import serializers
from .models import Notification
class NotificationSerializer(serializers.ModelSerializer):
type = serializers.CharField(source="notification_type", read_only=True)
unread = serializers.SerializerMethodField()
project_name = serializers.CharField(source="project.name", read_only=True)
class Meta:
model = Notification
fields = [
"id",
"type",
"notification_type",
"priority",
"title",
"brief",
"body",
"source",
"project",
"project_name",
"stage",
"owner_label",
"cost_label",
"related_url",
"is_read",
"unread",
"read_at",
"archived_at",
"metadata",
"created_at",
"updated_at",
]
read_only_fields = [
"id",
"type",
"project_name",
"read_at",
"archived_at",
"created_at",
"updated_at",
]
def get_unread(self, obj):
return not obj.is_read
+9
View File
@@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from .views import NotificationViewSet
router = DefaultRouter()
router.register("notifications", NotificationViewSet, basename="notification")
urlpatterns = router.urls
+167
View File
@@ -0,0 +1,167 @@
from django.db.models import Q
from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from apps.assets.models import Asset
from apps.billing.models import CreditAccount
from apps.common.api import TeamScopedViewSetMixin
from apps.projects.models import Project
from .models import Notification
from .serializers import NotificationSerializer
def project_stage_label(project):
return {
"script": "Stage 1 · 脚本",
"base_assets": "Stage 2 · 基础资产",
"storyboard": "Stage 3 · 故事板",
"video": "Stage 4 · 视频",
"export": "Stage 5 · 导出",
}.get(project.current_stage, "Stage 1 · 脚本")
def project_priority(project):
if project.status == Project.Status.COMPLETED:
return Notification.Priority.OK
if project.status == Project.Status.FAILED:
return Notification.Priority.ERR
return Notification.Priority.INFO
def ensure_team_notifications(team, user):
def create_once(dedupe_key, **payload):
Notification.objects.get_or_create(
team=team,
recipient=user,
dedupe_key=dedupe_key,
defaults=payload,
)
create_once(
"system:welcome",
notification_type=Notification.Type.SYSTEM,
priority=Notification.Priority.INFO,
title="团队已接入 AirShelf",
brief="真实消息中心已启用,状态会写入 Django 数据库。",
body="消息已从演示数据切换为团队级通知表。已读、未读、归档等操作都会持久化保存。",
source="Airshelf 系统",
stage="系统公告",
owner_label="系统",
cost_label="-",
related_url="settings.html#sec-notify",
)
for project in Project.objects.filter(team=team).select_related("product", "created_by").order_by("-updated_at")[:5]:
product_title = project.product.title if project.product_id else "未绑定商品"
create_once(
f"project:{project.id}:status:{project.status}:{project.current_stage}",
notification_type=Notification.Type.TASK,
priority=project_priority(project),
title=f"项目「{project.name}」状态更新",
brief=f"{product_title} · {project_stage_label(project)} · {project.get_status_display()}",
body=f"项目「{project.name}」当前处于 {project_stage_label(project)}。这条消息来自 Django 项目表,刷新后状态会保持一致。",
source="视频项目",
project=project,
stage=project_stage_label(project),
owner_label=project.created_by.username if project.created_by_id else "成员",
cost_label="-",
related_url=f"pipeline.html?project_id={project.id}",
metadata={"status": project.status, "current_stage": project.current_stage},
)
for asset in Asset.objects.filter(team=team).select_related("created_by").order_by("-updated_at")[:3]:
create_once(
f"asset:{asset.id}:created",
notification_type=Notification.Type.TASK,
priority=Notification.Priority.OK,
title=f"资产「{asset.name}」已加入资产库",
brief=f"{asset.get_category_display()} · {asset.get_asset_type_display()}",
body="资产记录来自真实资产表。后续上传、AI 生成、导出成片都可以在这里形成团队通知。",
source="资产库",
stage="资产入库",
owner_label=asset.created_by.username if asset.created_by_id else "成员",
cost_label="-",
related_url="library.html",
metadata={"asset_id": str(asset.id), "category": asset.category, "asset_type": asset.asset_type},
)
account, _ = CreditAccount.objects.get_or_create(team=team)
if account.balance <= 100:
create_once(
f"billing:low-balance:{account.id}",
notification_type=Notification.Type.BILLING,
priority=Notification.Priority.WARN,
title="团队余额低于预警线",
brief=f"当前余额 ¥{account.balance:.2f},建议及时充值。",
body="余额低于 100 元时系统会生成预警通知。充值或调低成员额度后可在消费页查看最新账本。",
source="计费中心",
stage="余额监控",
owner_label="系统",
cost_label=f"¥{account.balance:.2f}",
related_url="account.html",
)
class NotificationViewSet(TeamScopedViewSetMixin, ModelViewSet):
serializer_class = NotificationSerializer
queryset = Notification.objects.select_related("team", "recipient", "project").all()
search_fields = ["title", "brief", "body", "source", "stage"]
ordering_fields = ["created_at", "updated_at"]
ordering = ["-created_at"]
def get_queryset(self):
queryset = super().get_queryset().filter(archived_at__isnull=True)
user = self.request.user
queryset = queryset.filter(Q(recipient=user) | Q(recipient__isnull=True))
notification_type = self.request.query_params.get("type")
if notification_type and notification_type not in {"all", "unread"}:
queryset = queryset.filter(notification_type=notification_type)
if self.request.query_params.get("unread") in {"1", "true", "yes"}:
queryset = queryset.filter(is_read=False)
return queryset
def list(self, request, *args, **kwargs):
ensure_team_notifications(self.get_team(), request.user)
response = super().list(request, *args, **kwargs)
data = response.data
unread_count = self.get_queryset().filter(is_read=False).count()
if isinstance(data, dict):
data["unread_count"] = unread_count
return response
def perform_create(self, serializer):
serializer.save(team=self.get_team(), recipient=self.request.user)
@action(detail=False, methods=["post"], url_path="mark-all-read")
def mark_all_read(self, request):
now = timezone.now()
count = self.get_queryset().filter(is_read=False).update(is_read=True, read_at=now, updated_at=now)
return Response({"updated": count, "unread_count": self.get_queryset().filter(is_read=False).count()})
@action(detail=False, methods=["post"], url_path="mark-all-unread")
def mark_all_unread(self, request):
now = timezone.now()
count = self.get_queryset().filter(is_read=True).update(is_read=False, read_at=None, updated_at=now)
return Response({"updated": count, "unread_count": self.get_queryset().filter(is_read=False).count()})
@action(detail=True, methods=["post"], url_path="mark-read")
def mark_read(self, request, pk=None):
notification = self.get_object()
notification.mark_read()
return Response(self.get_serializer(notification).data)
@action(detail=True, methods=["post"], url_path="mark-unread")
def mark_unread(self, request, pk=None):
notification = self.get_object()
notification.mark_unread()
return Response(self.get_serializer(notification).data)
@action(detail=True, methods=["post"], url_path="archive")
def archive(self, request, pk=None):
notification = self.get_object()
notification.archive()
return Response(status=status.HTTP_204_NO_CONTENT)
+1
View File
@@ -0,0 +1 @@
+22
View File
@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import Product, ProductImage, ProductSellingPoint
class ProductImageInline(admin.TabularInline):
model = ProductImage
extra = 0
class ProductSellingPointInline(admin.TabularInline):
model = ProductSellingPoint
extra = 0
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("title", "team", "brand", "category", "status", "updated_at")
search_fields = ("title", "brand", "category", "team__name")
list_filter = ("status", "category")
inlines = [ProductImageInline, ProductSellingPointInline]
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ProductsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.products"
@@ -0,0 +1,157 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
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 = [
("accounts", "0001_initial"),
("assets", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Product",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=255)),
("brand", models.CharField(blank=True, max_length=128)),
("category", models.CharField(blank=True, max_length=128)),
("target_audience", models.CharField(blank=True, max_length=255)),
("specs", models.JSONField(blank=True, default=dict)),
("description", models.TextField(blank=True)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("archived", "Archived")],
default="active",
max_length=24,
),
),
(
"cover_asset",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="covered_products",
to="assets.asset",
),
),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="ProductImage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("is_primary", models.BooleanField(default=False)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="product_images",
to="assets.asset",
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="images",
to="products.product",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="ProductSellingPoint",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(max_length=128)),
("detail", models.TextField(blank=True)),
("sort_order", models.PositiveIntegerField(default=0)),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="selling_points",
to="products.product",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.AddIndex(
model_name="product",
index=models.Index(
fields=["team", "status"], name="products_pr_team_id_21af15_idx"
),
),
migrations.AddIndex(
model_name="product",
index=models.Index(
fields=["team", "category"], name="products_pr_team_id_1f3cfb_idx"
),
),
]
@@ -0,0 +1 @@
+54
View File
@@ -0,0 +1,54 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class Product(TeamOwnedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
ARCHIVED = "archived", "Archived"
title = models.CharField(max_length=255)
brand = models.CharField(max_length=128, blank=True)
category = models.CharField(max_length=128, blank=True)
target_audience = models.CharField(max_length=255, blank=True)
specs = models.JSONField(default=dict, blank=True)
description = models.TextField(blank=True)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
cover_asset = models.ForeignKey(
"assets.Asset",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="covered_products",
)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["team", "category"]),
]
def __str__(self) -> str:
return self.title
class ProductImage(TimeStampedModel):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="images")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="product_images")
sort_order = models.PositiveIntegerField(default=0)
is_primary = models.BooleanField(default=False)
class Meta:
ordering = ["sort_order", "created_at"]
class ProductSellingPoint(TimeStampedModel):
product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="selling_points")
title = models.CharField(max_length=128)
detail = models.TextField(blank=True)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["sort_order", "created_at"]
+65
View File
@@ -0,0 +1,65 @@
from rest_framework import serializers
from .models import Product, ProductImage, ProductSellingPoint
class ProductImageSerializer(serializers.ModelSerializer):
class Meta:
model = ProductImage
fields = ["id", "asset", "sort_order", "is_primary"]
class ProductSellingPointSerializer(serializers.ModelSerializer):
class Meta:
model = ProductSellingPoint
fields = ["id", "title", "detail", "sort_order"]
class ProductSerializer(serializers.ModelSerializer):
images = ProductImageSerializer(many=True, required=False)
selling_points = ProductSellingPointSerializer(many=True, required=False)
class Meta:
model = Product
fields = [
"id",
"title",
"brand",
"category",
"target_audience",
"specs",
"description",
"status",
"cover_asset",
"images",
"selling_points",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
def create(self, validated_data):
images = validated_data.pop("images", [])
selling_points = validated_data.pop("selling_points", [])
product = Product.objects.create(**validated_data)
self._sync_children(product, images, selling_points)
return product
def update(self, instance, validated_data):
images = validated_data.pop("images", None)
selling_points = validated_data.pop("selling_points", None)
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save()
self._sync_children(instance, images, selling_points)
return instance
def _sync_children(self, product, images, selling_points):
if images is not None:
product.images.all().delete()
for item in images:
ProductImage.objects.create(product=product, **item)
if selling_points is not None:
product.selling_points.all().delete()
for item in selling_points:
ProductSellingPoint.objects.create(product=product, **item)
+9
View File
@@ -0,0 +1,9 @@
from rest_framework.routers import DefaultRouter
from .views import ProductViewSet
router = DefaultRouter()
router.register("", ProductViewSet, basename="product")
urlpatterns = router.urls
+14
View File
@@ -0,0 +1,14 @@
from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin
from .models import Product
from .serializers import ProductSerializer
class ProductViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Product.objects.prefetch_related("images", "selling_points").all()
serializer_class = ProductSerializer
search_fields = ["title", "brand", "category"]
ordering_fields = ["created_at", "updated_at", "title"]
+1
View File
@@ -0,0 +1 @@
+46
View File
@@ -0,0 +1,46 @@
from django.contrib import admin
from .models import (
BaseAssetGroup,
BgmTrack,
ExportJob,
Project,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
SubtitleTrack,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
class ProjectStageInline(admin.TabularInline):
model = ProjectStage
extra = 0
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ("name", "team", "product", "status", "current_stage", "updated_at")
search_fields = ("name", "team__name", "product__title")
list_filter = ("status", "current_stage")
inlines = [ProjectStageInline]
admin.site.register(ScriptVersion)
admin.site.register(ScriptSegment)
admin.site.register(BaseAssetGroup)
admin.site.register(StoryboardVersion)
admin.site.register(StoryboardFrame)
admin.site.register(VideoSegment)
admin.site.register(VideoSegmentVersion)
admin.site.register(Timeline)
admin.site.register(TimelineClip)
admin.site.register(SubtitleTrack)
admin.site.register(BgmTrack)
admin.site.register(ExportJob)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ProjectsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.projects"
@@ -0,0 +1,721 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
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 = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
("assets", "0001_initial"),
("products", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Project",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
(
"status",
models.CharField(
choices=[
("draft", "Draft"),
("scripting", "Scripting"),
("asseting", "Asseting"),
("storyboarding", "Storyboarding"),
("videoing", "Videoing"),
("exporting", "Exporting"),
("completed", "Completed"),
("failed", "Failed"),
],
default="draft",
max_length=32,
),
),
("current_stage", models.CharField(default="script", max_length=32)),
(
"budget_limit",
models.DecimalField(
blank=True, decimal_places=2, max_digits=12, null=True
),
),
("failure_reason", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"product",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="projects",
to="products.product",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="BaseAssetGroup",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"kind",
models.CharField(
choices=[
("product", "Product"),
("person", "Person"),
("scene", "Scene"),
],
max_length=32,
),
),
("prompt", models.TextField(blank=True)),
("version", models.PositiveIntegerField(default=1)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"adopted_asset",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="adopted_base_groups",
to="assets.asset",
),
),
(
"candidate_assets",
models.ManyToManyField(
blank=True,
related_name="candidate_base_groups",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="base_asset_groups",
to="ai.aitask",
),
),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="base_asset_groups",
to="projects.project",
),
),
],
),
migrations.CreateModel(
name="ProjectStage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"stage",
models.CharField(
choices=[
("script", "Script"),
("base_assets", "Base Assets"),
("storyboard", "Storyboard"),
("video", "Video"),
("export", "Export"),
],
max_length=32,
),
),
(
"status",
models.CharField(
choices=[
("not_started", "Not Started"),
("draft", "Draft"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("skipped", "Skipped"),
("needs_review", "Needs Review"),
],
default="not_started",
max_length=32,
),
),
("started_at", models.DateTimeField(blank=True, null=True)),
("completed_at", models.DateTimeField(blank=True, null=True)),
("error_message", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="stages",
to="projects.project",
),
),
],
options={
"ordering": ["created_at"],
},
),
migrations.CreateModel(
name="ScriptVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("title", models.CharField(blank=True, max_length=128)),
("content", models.TextField()),
("source", models.CharField(default="ai", max_length=32)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="script_versions",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="script_versions",
to="ai.aitask",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ScriptSegment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("duration_seconds", models.PositiveIntegerField(default=15)),
("narration", models.TextField(blank=True)),
("visual_prompt", models.TextField(blank=True)),
("product_points", models.JSONField(blank=True, default=list)),
(
"script_version",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="segments",
to="projects.scriptversion",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="StoryboardVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("prompt", models.TextField(blank=True)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="storyboard_versions",
to="projects.project",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="storyboard_versions",
to="ai.aitask",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="StoryboardFrame",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("prompt", models.TextField(blank=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="storyboard_frames",
to="assets.asset",
),
),
(
"script_segment",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="storyboard_frames",
to="projects.scriptsegment",
),
),
(
"storyboard",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="frames",
to="projects.storyboardversion",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="Timeline",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(blank=True, max_length=255)),
("aspect_ratio", models.CharField(default="9:16", max_length=16)),
("resolution", models.CharField(default="1080x1920", max_length=32)),
("duration_seconds", models.PositiveIntegerField(default=60)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"project",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="timeline",
to="projects.project",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="SubtitleTrack",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("content", models.JSONField(blank=True, default=list)),
("style", models.JSONField(blank=True, default=dict)),
("enabled", models.BooleanField(default=True)),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="subtitle_tracks",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="ExportJob",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
models.CharField(
choices=[
("draft", "Draft"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
],
default="draft",
max_length=32,
),
),
("progress", models.PositiveIntegerField(default=0)),
("error_message", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"output_asset",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="export_jobs",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="export_jobs",
to="ai.aitask",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="export_jobs",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="BgmTrack",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("volume", models.PositiveIntegerField(default=60)),
("start_ms", models.PositiveIntegerField(default=0)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="bgm_tracks",
to="assets.asset",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="bgm_tracks",
to="projects.timeline",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TimelineClip",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("start_ms", models.PositiveIntegerField(default=0)),
("duration_ms", models.PositiveIntegerField(default=15000)),
("trim_start_ms", models.PositiveIntegerField(default=0)),
("trim_end_ms", models.PositiveIntegerField(blank=True, null=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="timeline_clips",
to="assets.asset",
),
),
(
"timeline",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="clips",
to="projects.timeline",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="VideoSegment",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("sort_order", models.PositiveIntegerField(default=0)),
("target_duration_seconds", models.PositiveIntegerField(default=15)),
(
"status",
models.CharField(
choices=[
("not_started", "Not Started"),
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
],
default="not_started",
max_length=32,
),
),
("error_message", models.TextField(blank=True)),
(
"project",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="video_segments",
to="projects.project",
),
),
(
"script_segment",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="video_segments",
to="projects.scriptsegment",
),
),
],
options={
"ordering": ["sort_order", "created_at"],
},
),
migrations.CreateModel(
name="VideoSegmentVersion",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("prompt", models.TextField(blank=True)),
("is_adopted", models.BooleanField(default=False)),
("metadata", models.JSONField(blank=True, default=dict)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="video_segment_versions",
to="assets.asset",
),
),
(
"task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="video_versions",
to="ai.aitask",
),
),
(
"video_segment",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="versions",
to="projects.videosegment",
),
),
],
options={
"abstract": False,
},
),
migrations.AddField(
model_name="videosegment",
name="adopted_version",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="adopted_by_segments",
to="projects.videosegmentversion",
),
),
migrations.AddIndex(
model_name="project",
index=models.Index(
fields=["team", "status"], name="projects_pr_team_id_4a0091_idx"
),
),
migrations.AddIndex(
model_name="project",
index=models.Index(
fields=["team", "current_stage"], name="projects_pr_team_id_a3c9ff_idx"
),
),
migrations.AddIndex(
model_name="baseassetgroup",
index=models.Index(
fields=["project", "kind"], name="projects_ba_project_8fb70a_idx"
),
),
migrations.AlterUniqueTogether(
name="projectstage",
unique_together={("project", "stage")},
),
migrations.AlterUniqueTogether(
name="videosegment",
unique_together={("project", "sort_order")},
),
]
@@ -0,0 +1 @@
+237
View File
@@ -0,0 +1,237 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class Project(TeamOwnedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
SCRIPTING = "scripting", "Scripting"
ASSETING = "asseting", "Asseting"
STORYBOARDING = "storyboarding", "Storyboarding"
VIDEOING = "videoing", "Videoing"
EXPORTING = "exporting", "Exporting"
COMPLETED = "completed", "Completed"
FAILED = "failed", "Failed"
name = models.CharField(max_length=255)
product = models.ForeignKey("products.Product", on_delete=models.PROTECT, related_name="projects")
status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT)
current_stage = models.CharField(max_length=32, default="script")
budget_limit = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True)
failure_reason = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [
models.Index(fields=["team", "status"]),
models.Index(fields=["team", "current_stage"]),
]
def __str__(self) -> str:
return self.name
class ProjectStage(TimeStampedModel):
class Stage(models.TextChoices):
SCRIPT = "script", "Script"
BASE_ASSETS = "base_assets", "Base Assets"
STORYBOARD = "storyboard", "Storyboard"
VIDEO = "video", "Video"
EXPORT = "export", "Export"
class Status(models.TextChoices):
NOT_STARTED = "not_started", "Not Started"
DRAFT = "draft", "Draft"
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
SKIPPED = "skipped", "Skipped"
NEEDS_REVIEW = "needs_review", "Needs Review"
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="stages")
stage = models.CharField(max_length=32, choices=Stage.choices)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED)
started_at = models.DateTimeField(null=True, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
error_message = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
unique_together = [("project", "stage")]
ordering = ["created_at"]
class ScriptVersion(TimeStampedModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions")
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions")
title = models.CharField(max_length=128, blank=True)
content = models.TextField()
source = models.CharField(max_length=32, default="ai")
is_adopted = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class ScriptSegment(TimeStampedModel):
script_version = models.ForeignKey(ScriptVersion, on_delete=models.CASCADE, related_name="segments")
sort_order = models.PositiveIntegerField(default=0)
duration_seconds = models.PositiveIntegerField(default=15)
narration = models.TextField(blank=True)
visual_prompt = models.TextField(blank=True)
product_points = models.JSONField(default=list, blank=True)
class Meta:
ordering = ["sort_order", "created_at"]
class BaseAssetGroup(TimeStampedModel):
class Kind(models.TextChoices):
PRODUCT = "product", "Product"
PERSON = "person", "Person"
SCENE = "scene", "Scene"
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="base_asset_groups")
kind = models.CharField(max_length=32, choices=Kind.choices)
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="base_asset_groups")
prompt = models.TextField(blank=True)
adopted_asset = models.ForeignKey(
"assets.Asset",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="adopted_base_groups",
)
candidate_assets = models.ManyToManyField("assets.Asset", blank=True, related_name="candidate_base_groups")
version = models.PositiveIntegerField(default=1)
metadata = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [models.Index(fields=["project", "kind"])]
class StoryboardVersion(TimeStampedModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="storyboard_versions")
task = models.ForeignKey(
"ai.AITask",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="storyboard_versions",
)
prompt = models.TextField(blank=True)
is_adopted = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class StoryboardFrame(TimeStampedModel):
storyboard = models.ForeignKey(StoryboardVersion, on_delete=models.CASCADE, related_name="frames")
script_segment = models.ForeignKey(
ScriptSegment,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="storyboard_frames",
)
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="storyboard_frames")
sort_order = models.PositiveIntegerField(default=0)
prompt = models.TextField(blank=True)
class Meta:
ordering = ["sort_order", "created_at"]
class VideoSegment(TimeStampedModel):
class Status(models.TextChoices):
NOT_STARTED = "not_started", "Not Started"
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="video_segments")
script_segment = models.ForeignKey(
ScriptSegment,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="video_segments",
)
sort_order = models.PositiveIntegerField(default=0)
target_duration_seconds = models.PositiveIntegerField(default=15)
status = models.CharField(max_length=32, choices=Status.choices, default=Status.NOT_STARTED)
adopted_version = models.ForeignKey(
"projects.VideoSegmentVersion",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="adopted_by_segments",
)
error_message = models.TextField(blank=True)
class Meta:
unique_together = [("project", "sort_order")]
ordering = ["sort_order", "created_at"]
class VideoSegmentVersion(TimeStampedModel):
video_segment = models.ForeignKey(VideoSegment, on_delete=models.CASCADE, related_name="versions")
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="video_versions")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="video_segment_versions")
prompt = models.TextField(blank=True)
is_adopted = models.BooleanField(default=False)
metadata = models.JSONField(default=dict, blank=True)
class Timeline(TimeStampedModel):
project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="timeline")
name = models.CharField(max_length=255, blank=True)
aspect_ratio = models.CharField(max_length=16, default="9:16")
resolution = models.CharField(max_length=32, default="1080x1920")
duration_seconds = models.PositiveIntegerField(default=60)
metadata = models.JSONField(default=dict, blank=True)
class TimelineClip(TimeStampedModel):
timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="clips")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="timeline_clips")
sort_order = models.PositiveIntegerField(default=0)
start_ms = models.PositiveIntegerField(default=0)
duration_ms = models.PositiveIntegerField(default=15000)
trim_start_ms = models.PositiveIntegerField(default=0)
trim_end_ms = models.PositiveIntegerField(null=True, blank=True)
class Meta:
ordering = ["sort_order", "created_at"]
class SubtitleTrack(TimeStampedModel):
timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="subtitle_tracks")
content = models.JSONField(default=list, blank=True)
style = models.JSONField(default=dict, blank=True)
enabled = models.BooleanField(default=True)
class BgmTrack(TimeStampedModel):
timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="bgm_tracks")
asset = models.ForeignKey("assets.Asset", on_delete=models.PROTECT, related_name="bgm_tracks")
volume = models.PositiveIntegerField(default=60)
start_ms = models.PositiveIntegerField(default=0)
class ExportJob(TimeStampedModel):
class Status(models.TextChoices):
DRAFT = "draft", "Draft"
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
timeline = models.ForeignKey(Timeline, on_delete=models.CASCADE, related_name="export_jobs")
status = models.CharField(max_length=32, choices=Status.choices, default=Status.DRAFT)
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="export_jobs")
output_asset = models.ForeignKey("assets.Asset", on_delete=models.SET_NULL, null=True, blank=True, related_name="export_jobs")
progress = models.PositiveIntegerField(default=0)
error_message = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
+140
View File
@@ -0,0 +1,140 @@
from rest_framework import serializers
from .models import (
BaseAssetGroup,
ExportJob,
Project,
ProjectStage,
ScriptSegment,
ScriptVersion,
StoryboardFrame,
StoryboardVersion,
Timeline,
TimelineClip,
VideoSegment,
VideoSegmentVersion,
)
class ProjectStageSerializer(serializers.ModelSerializer):
class Meta:
model = ProjectStage
fields = ["id", "stage", "status", "started_at", "completed_at", "error_message", "metadata"]
read_only_fields = fields
class VideoSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = VideoSegment
fields = ["id", "sort_order", "target_duration_seconds", "status", "error_message", "adopted_version"]
read_only_fields = fields
class BaseAssetGroupSerializer(serializers.ModelSerializer):
candidate_assets = serializers.PrimaryKeyRelatedField(many=True, read_only=True)
class Meta:
model = BaseAssetGroup
fields = ["id", "kind", "prompt", "adopted_asset", "candidate_assets", "version", "metadata", "created_at"]
read_only_fields = fields
class StoryboardFrameSerializer(serializers.ModelSerializer):
class Meta:
model = StoryboardFrame
fields = ["id", "script_segment", "asset", "sort_order", "prompt"]
read_only_fields = fields
class StoryboardVersionSerializer(serializers.ModelSerializer):
frames = StoryboardFrameSerializer(many=True, read_only=True)
class Meta:
model = StoryboardVersion
fields = ["id", "prompt", "is_adopted", "frames", "created_at", "updated_at"]
read_only_fields = fields
class VideoSegmentVersionSerializer(serializers.ModelSerializer):
class Meta:
model = VideoSegmentVersion
fields = ["id", "video_segment", "asset", "prompt", "is_adopted", "metadata", "created_at"]
read_only_fields = fields
class TimelineClipSerializer(serializers.ModelSerializer):
class Meta:
model = TimelineClip
fields = ["id", "asset", "sort_order", "start_ms", "duration_ms", "trim_start_ms", "trim_end_ms"]
read_only_fields = ["id"]
class TimelineExportJobSerializer(serializers.ModelSerializer):
class Meta:
model = ExportJob
fields = ["id", "status", "output_asset", "progress", "error_message", "created_at", "updated_at"]
read_only_fields = fields
class TimelineSerializer(serializers.ModelSerializer):
clips = TimelineClipSerializer(many=True, read_only=True)
export_jobs = TimelineExportJobSerializer(many=True, read_only=True)
class Meta:
model = Timeline
fields = ["id", "name", "aspect_ratio", "resolution", "duration_seconds", "metadata", "clips", "export_jobs"]
read_only_fields = ["id", "clips", "export_jobs"]
class ExportJobSerializer(serializers.ModelSerializer):
class Meta:
model = ExportJob
fields = ["id", "status", "output_asset", "progress", "error_message", "metadata", "created_at", "updated_at"]
read_only_fields = fields
class ScriptSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptSegment
fields = ["id", "sort_order", "duration_seconds", "narration", "visual_prompt", "product_points"]
read_only_fields = fields
class ScriptVersionSerializer(serializers.ModelSerializer):
segments = ScriptSegmentSerializer(many=True, read_only=True)
class Meta:
model = ScriptVersion
fields = ["id", "title", "content", "source", "is_adopted", "segments", "created_at", "updated_at"]
read_only_fields = fields
class ProjectSerializer(serializers.ModelSerializer):
stages = ProjectStageSerializer(many=True, read_only=True)
video_segments = VideoSegmentSerializer(many=True, read_only=True)
script_versions = ScriptVersionSerializer(many=True, read_only=True)
base_asset_groups = BaseAssetGroupSerializer(many=True, read_only=True)
storyboard_versions = StoryboardVersionSerializer(many=True, read_only=True)
timeline = TimelineSerializer(read_only=True)
class Meta:
model = Project
fields = [
"id",
"name",
"product",
"status",
"current_stage",
"budget_limit",
"failure_reason",
"metadata",
"stages",
"script_versions",
"base_asset_groups",
"storyboard_versions",
"video_segments",
"timeline",
"created_at",
"updated_at",
]
read_only_fields = ["id", "status", "current_stage", "failure_reason", "created_at", "updated_at"]
@@ -0,0 +1 @@
@@ -0,0 +1,105 @@
from pathlib import Path
import subprocess
import tempfile
import requests
from django.db import transaction
from apps.assets.models import Asset, AssetFile
from apps.assets.storage import TosStorage
from apps.projects.models import ExportJob
def _download_asset_primary_file(asset, target_path: Path) -> None:
primary = asset.files.filter(is_primary=True).first() or asset.files.first()
if primary is None:
raise ValueError(f"asset {asset.id} has no file")
url = TosStorage().presigned_get_url(object_key=primary.object_key, expires_in=3600)
response = requests.get(url, timeout=180)
response.raise_for_status()
target_path.write_bytes(response.content)
def run_export_job(export_job_id: str) -> ExportJob:
export_job = ExportJob.objects.select_related("timeline", "timeline__project").get(id=export_job_id)
timeline = export_job.timeline
project = timeline.project
clips = list(timeline.clips.select_related("asset").order_by("sort_order"))
if not clips:
raise ValueError("timeline has no clips")
export_job.status = ExportJob.Status.RUNNING
export_job.progress = 10
export_job.save(update_fields=["status", "progress", "updated_at"])
with tempfile.TemporaryDirectory(prefix="airshelf-export-") as tmp_dir:
tmp = Path(tmp_dir)
concat_file = tmp / "concat.txt"
downloaded_files: list[Path] = []
for index, clip in enumerate(clips):
clip_path = tmp / f"clip-{index}.mp4"
_download_asset_primary_file(clip.asset, clip_path)
downloaded_files.append(clip_path)
concat_file.write_text(
"\n".join(f"file '{path.as_posix()}'" for path in downloaded_files),
encoding="utf-8",
)
output_path = tmp / "output.mp4"
command = [
"ffmpeg",
"-y",
"-f",
"concat",
"-safe",
"0",
"-i",
str(concat_file),
"-vf",
"scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2",
"-r",
"30",
"-c:v",
"libx264",
"-pix_fmt",
"yuv420p",
"-c:a",
"aac",
"-movflags",
"+faststart",
str(output_path),
]
subprocess.run(command, check=True, capture_output=True)
export_job.progress = 85
export_job.save(update_fields=["progress", "updated_at"])
with output_path.open("rb") as fileobj:
asset_id = export_job.id
object_key = f"teams/{project.team_id}/projects/{project.id}/exports/{asset_id}.mp4"
stored = TosStorage().upload_fileobj(fileobj=fileobj, object_key=object_key, content_type="video/mp4")
with transaction.atomic():
asset = Asset.objects.create(
team=project.team,
created_by=project.created_by,
name=f"{project.name}-final.mp4",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.EXPORTED,
category=Asset.Category.FINAL_VIDEO,
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
export_job.output_asset = asset
export_job.status = ExportJob.Status.SUCCEEDED
export_job.progress = 100
export_job.error_message = ""
export_job.save(update_fields=["output_asset", "status", "progress", "error_message", "updated_at"])
project.status = project.Status.COMPLETED
project.save(update_fields=["status", "updated_at"])
return export_job
@@ -0,0 +1,37 @@
from dataclasses import dataclass
from apps.projects.models import ProjectStage
STAGE_ORDER = [
ProjectStage.Stage.SCRIPT,
ProjectStage.Stage.BASE_ASSETS,
ProjectStage.Stage.STORYBOARD,
ProjectStage.Stage.VIDEO,
ProjectStage.Stage.EXPORT,
]
@dataclass(frozen=True)
class StageTransition:
current: str
target: str
allowed: bool
reason: str = ""
def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard: bool = True) -> StageTransition:
if target_stage not in STAGE_ORDER:
return StageTransition(current_stage, target_stage, False, "unknown target stage")
current_index = STAGE_ORDER.index(current_stage) if current_stage in STAGE_ORDER else -1
target_index = STAGE_ORDER.index(target_stage)
if target_index <= current_index + 1:
return StageTransition(current_stage, target_stage, True)
if allow_skip_storyboard and current_stage == ProjectStage.Stage.BASE_ASSETS and target_stage == ProjectStage.Stage.VIDEO:
return StageTransition(current_stage, target_stage, True)
return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied")

Some files were not shown because too many files have changed in this diff Show More