feat: add AirShelf core implementation

This commit is contained in:
zyc
2026-06-05 10:21:40 +08:00
parent 2ba1058329
commit cfdcd84a30
252 changed files with 70828 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
+22
View File
@@ -0,0 +1,22 @@
from django.contrib import admin
from .models import Asset, AssetFile, AssetTag, AssetTagging, AssetUsage
class AssetFileInline(admin.TabularInline):
model = AssetFile
extra = 0
@admin.register(Asset)
class AssetAdmin(admin.ModelAdmin):
list_display = ("name", "team", "asset_type", "source", "category", "is_deleted", "created_at")
search_fields = ("name", "team__name")
list_filter = ("asset_type", "source", "category", "is_deleted")
inlines = [AssetFileInline]
admin.site.register(AssetTag)
admin.site.register(AssetTagging)
admin.site.register(AssetUsage)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AssetsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.assets"
@@ -0,0 +1,232 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("accounts", "0001_initial"),
("ai", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Asset",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=255)),
(
"asset_type",
models.CharField(
choices=[
("image", "Image"),
("video", "Video"),
("audio", "Audio"),
("subtitle", "Subtitle"),
("document", "Document"),
],
max_length=24,
),
),
(
"source",
models.CharField(
choices=[
("upload", "Upload"),
("ai_generated", "AI Generated"),
("exported", "Exported"),
("system", "System"),
],
max_length=32,
),
),
(
"category",
models.CharField(
choices=[
("person", "Person"),
("scene", "Scene"),
("product_image", "Product Image"),
("video_clip", "Video Clip"),
("final_video", "Final Video"),
("upload", "Upload"),
("uncategorized", "Uncategorized"),
],
default="uncategorized",
max_length=32,
),
),
("description", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("is_deleted", models.BooleanField(default=False)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"origin_task",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="generated_assets",
to="ai.aitask",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="AssetFile",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("object_key", models.CharField(max_length=512)),
("bucket", models.CharField(max_length=128)),
("content_type", models.CharField(blank=True, max_length=128)),
("size_bytes", models.BigIntegerField(default=0)),
("checksum", models.CharField(blank=True, max_length=128)),
("width", models.PositiveIntegerField(blank=True, null=True)),
("height", models.PositiveIntegerField(blank=True, null=True)),
("duration_ms", models.PositiveIntegerField(blank=True, null=True)),
("preview_url", models.URLField(blank=True)),
("is_primary", models.BooleanField(default=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="files",
to="assets.asset",
),
),
],
),
migrations.CreateModel(
name="AssetTag",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=64)),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="asset_tags",
to="accounts.team",
),
),
],
),
migrations.CreateModel(
name="AssetTagging",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="taggings",
to="assets.asset",
),
),
(
"tag",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="taggings",
to="assets.assettag",
),
),
],
),
migrations.CreateModel(
name="AssetUsage",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("usage_type", models.CharField(max_length=64)),
("context", models.JSONField(blank=True, default=dict)),
(
"asset",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="usages",
to="assets.asset",
),
),
],
options={
"abstract": False,
},
),
]
@@ -0,0 +1,42 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("assets", "0001_initial"),
("projects", "0001_initial"),
]
operations = [
migrations.AddField(
model_name="assetusage",
name="project",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="asset_usages",
to="projects.project",
),
),
migrations.AddIndex(
model_name="assetfile",
index=models.Index(
fields=["bucket", "object_key"], name="assets_asse_bucket_94a505_idx"
),
),
migrations.AlterUniqueTogether(
name="assettag",
unique_together={("team", "name")},
),
migrations.AlterUniqueTogether(
name="assettagging",
unique_together={("asset", "tag")},
),
]
@@ -0,0 +1 @@
+100
View File
@@ -0,0 +1,100 @@
from django.db import models
from apps.common.models import TeamOwnedModel, TimeStampedModel
class Asset(TeamOwnedModel):
class Type(models.TextChoices):
IMAGE = "image", "Image"
VIDEO = "video", "Video"
AUDIO = "audio", "Audio"
SUBTITLE = "subtitle", "Subtitle"
DOCUMENT = "document", "Document"
class Source(models.TextChoices):
UPLOAD = "upload", "Upload"
AI_GENERATED = "ai_generated", "AI Generated"
EXPORTED = "exported", "Exported"
SYSTEM = "system", "System"
class Category(models.TextChoices):
PERSON = "person", "Person"
SCENE = "scene", "Scene"
PRODUCT_IMAGE = "product_image", "Product Image"
VIDEO_CLIP = "video_clip", "Video Clip"
FINAL_VIDEO = "final_video", "Final Video"
UPLOAD = "upload", "Upload"
UNCATEGORIZED = "uncategorized", "Uncategorized"
name = models.CharField(max_length=255)
asset_type = models.CharField(max_length=24, choices=Type.choices)
source = models.CharField(max_length=32, choices=Source.choices)
category = models.CharField(max_length=32, choices=Category.choices, default=Category.UNCATEGORIZED)
description = models.TextField(blank=True)
origin_task = models.ForeignKey(
"ai.AITask",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="generated_assets",
)
metadata = models.JSONField(default=dict, blank=True)
is_deleted = models.BooleanField(default=False)
def __str__(self) -> str:
return self.name
class AssetFile(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="files")
object_key = models.CharField(max_length=512)
bucket = models.CharField(max_length=128)
content_type = models.CharField(max_length=128, blank=True)
size_bytes = models.BigIntegerField(default=0)
checksum = models.CharField(max_length=128, blank=True)
width = models.PositiveIntegerField(null=True, blank=True)
height = models.PositiveIntegerField(null=True, blank=True)
duration_ms = models.PositiveIntegerField(null=True, blank=True)
preview_url = models.URLField(blank=True)
is_primary = models.BooleanField(default=True)
class Meta:
indexes = [
models.Index(fields=["bucket", "object_key"]),
]
def __str__(self) -> str:
return self.object_key
class AssetTag(TimeStampedModel):
team = models.ForeignKey("accounts.Team", on_delete=models.CASCADE, related_name="asset_tags")
name = models.CharField(max_length=64)
class Meta:
unique_together = [("team", "name")]
def __str__(self) -> str:
return self.name
class AssetTagging(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="taggings")
tag = models.ForeignKey(AssetTag, on_delete=models.CASCADE, related_name="taggings")
class Meta:
unique_together = [("asset", "tag")]
class AssetUsage(TimeStampedModel):
asset = models.ForeignKey(Asset, on_delete=models.CASCADE, related_name="usages")
project = models.ForeignKey(
"projects.Project",
on_delete=models.CASCADE,
null=True,
blank=True,
related_name="asset_usages",
)
usage_type = models.CharField(max_length=64)
context = models.JSONField(default=dict, blank=True)
+51
View File
@@ -0,0 +1,51 @@
from rest_framework import serializers
from .models import Asset, AssetFile
class AssetFileSerializer(serializers.ModelSerializer):
class Meta:
model = AssetFile
fields = [
"id",
"object_key",
"bucket",
"content_type",
"size_bytes",
"width",
"height",
"duration_ms",
"preview_url",
"is_primary",
]
read_only_fields = fields
class AssetSerializer(serializers.ModelSerializer):
files = AssetFileSerializer(many=True, read_only=True)
class Meta:
model = Asset
fields = [
"id",
"name",
"asset_type",
"source",
"category",
"description",
"metadata",
"is_deleted",
"files",
"created_at",
"updated_at",
]
read_only_fields = ["id", "created_at", "updated_at"]
class AssetUploadSerializer(serializers.Serializer):
file = serializers.FileField()
name = serializers.CharField(max_length=255, required=False, allow_blank=True)
asset_type = serializers.ChoiceField(choices=Asset.Type.choices)
category = serializers.ChoiceField(choices=Asset.Category.choices, default=Asset.Category.UPLOAD)
description = serializers.CharField(required=False, allow_blank=True)
+52
View File
@@ -0,0 +1,52 @@
from dataclasses import dataclass
from typing import BinaryIO
import boto3
from botocore.config import Config
from django.conf import settings
@dataclass(frozen=True)
class StoredObject:
bucket: str
object_key: str
content_type: str
size_bytes: int
class TosStorage:
def __init__(self) -> None:
tos = settings.TOS
self.bucket = tos["bucket"]
self.client = boto3.client(
"s3",
endpoint_url=tos["endpoint"],
aws_access_key_id=tos["access_key_id"],
aws_secret_access_key=tos["secret_access_key"],
region_name="cn-shanghai",
config=Config(s3={"addressing_style": "virtual"}),
)
def upload_fileobj(self, *, fileobj: BinaryIO, object_key: str, content_type: str) -> StoredObject:
fileobj.seek(0, 2)
size = fileobj.tell()
fileobj.seek(0)
self.client.upload_fileobj(
fileobj,
self.bucket,
object_key,
ExtraArgs={"ContentType": content_type},
)
return StoredObject(
bucket=self.bucket,
object_key=object_key,
content_type=content_type,
size_bytes=size,
)
def presigned_get_url(self, *, object_key: str, expires_in: int = 3600) -> str:
return self.client.generate_presigned_url(
"get_object",
Params={"Bucket": self.bucket, "Key": object_key},
ExpiresIn=expires_in,
)
+11
View File
@@ -0,0 +1,11 @@
from django.urls import path
from rest_framework.routers import DefaultRouter
from .views import AssetUploadView, AssetViewSet
router = DefaultRouter()
router.register("", AssetViewSet, basename="asset")
urlpatterns = [
path("upload/", AssetUploadView.as_view(), name="asset-upload"),
] + router.urls
+61
View File
@@ -0,0 +1,61 @@
from pathlib import Path
import uuid
from django.db import transaction
from rest_framework import status
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet
from apps.common.api import TeamScopedViewSetMixin, get_current_team
from .models import Asset, AssetFile
from .serializers import AssetSerializer, AssetUploadSerializer
from .storage import TosStorage
class AssetViewSet(TeamScopedViewSetMixin, ModelViewSet):
queryset = Asset.objects.prefetch_related("files").all()
serializer_class = AssetSerializer
search_fields = ["name", "description"]
ordering_fields = ["created_at", "updated_at", "name"]
class AssetUploadView(APIView):
parser_classes = [MultiPartParser, FormParser]
@transaction.atomic
def post(self, request):
serializer = AssetUploadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
team = get_current_team(request.user)
upload = serializer.validated_data["file"]
suffix = Path(upload.name).suffix.lower()
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
fileobj=upload.file,
object_key=object_key,
content_type=upload.content_type or "application/octet-stream",
)
asset = Asset.objects.create(
id=asset_id,
team=team,
created_by=request.user,
name=serializer.validated_data.get("name") or upload.name,
asset_type=serializer.validated_data["asset_type"],
source=Asset.Source.UPLOAD,
category=serializer.validated_data["category"],
description=serializer.validated_data.get("description", ""),
)
AssetFile.objects.create(
asset=asset,
object_key=stored.object_key,
bucket=stored.bucket,
content_type=stored.content_type,
size_bytes=stored.size_bytes,
is_primary=True,
)
return Response(AssetSerializer(asset).data, status=status.HTTP_201_CREATED)