feat: add AirShelf core implementation
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user