feat: add AirShelf core implementation
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
try:
|
||||
import pymysql
|
||||
|
||||
pymysql.install_as_MySQLdb()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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")),
|
||||
]
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AiConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.ai"
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
...
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.http import JsonResponse
|
||||
|
||||
|
||||
def health_check(request):
|
||||
return JsonResponse({"status": "ok", "service": "airshelf-backend"})
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
+33
@@ -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 @@
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,9 @@
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from .views import ProductViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register("", ProductViewSet, basename="product")
|
||||
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user