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