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 @@
+25
View File
@@ -0,0 +1,25 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import Team, TeamMember, User
@admin.register(User)
class AirShelfUserAdmin(UserAdmin):
list_display = ("username", "email", "status", "is_staff", "date_joined")
list_filter = ("status", "is_staff", "is_superuser")
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
list_display = ("name", "owner", "status", "created_at")
search_fields = ("name", "owner__username", "owner__email")
list_filter = ("status",)
@admin.register(TeamMember)
class TeamMemberAdmin(admin.ModelAdmin):
list_display = ("team", "user", "role", "status", "monthly_credit_limit")
search_fields = ("team__name", "user__username", "user__email")
list_filter = ("role", "status")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
@@ -0,0 +1,245 @@
# Generated by Django 5.1.15 on 2026-05-29 03:59
import django.contrib.auth.models
import django.contrib.auth.validators
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
]
operations = [
migrations.CreateModel(
name="User",
fields=[
("password", models.CharField(max_length=128, verbose_name="password")),
(
"last_login",
models.DateTimeField(
blank=True, null=True, verbose_name="last login"
),
),
(
"is_superuser",
models.BooleanField(
default=False,
help_text="Designates that this user has all permissions without explicitly assigning them.",
verbose_name="superuser status",
),
),
(
"username",
models.CharField(
error_messages={
"unique": "A user with that username already exists."
},
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
max_length=150,
unique=True,
validators=[
django.contrib.auth.validators.UnicodeUsernameValidator()
],
verbose_name="username",
),
),
(
"first_name",
models.CharField(
blank=True, max_length=150, verbose_name="first name"
),
),
(
"last_name",
models.CharField(
blank=True, max_length=150, verbose_name="last name"
),
),
(
"email",
models.EmailField(
blank=True, max_length=254, verbose_name="email address"
),
),
(
"is_staff",
models.BooleanField(
default=False,
help_text="Designates whether the user can log into this admin site.",
verbose_name="staff status",
),
),
(
"is_active",
models.BooleanField(
default=True,
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
verbose_name="active",
),
),
(
"date_joined",
models.DateTimeField(
default=django.utils.timezone.now, verbose_name="date joined"
),
),
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
("phone", models.CharField(blank=True, max_length=32)),
("avatar_url", models.URLField(blank=True)),
(
"groups",
models.ManyToManyField(
blank=True,
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
related_name="user_set",
related_query_name="user",
to="auth.group",
verbose_name="groups",
),
),
(
"user_permissions",
models.ManyToManyField(
blank=True,
help_text="Specific permissions for this user.",
related_name="user_set",
related_query_name="user",
to="auth.permission",
verbose_name="user permissions",
),
),
],
options={
"verbose_name": "user",
"verbose_name_plural": "users",
"abstract": False,
},
managers=[
("objects", django.contrib.auth.models.UserManager()),
],
),
migrations.CreateModel(
name="Team",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
("name", models.CharField(max_length=128)),
(
"status",
models.CharField(
choices=[("active", "Active"), ("disabled", "Disabled")],
default="active",
max_length=24,
),
),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
related_name="owned_teams",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"abstract": False,
},
),
migrations.CreateModel(
name="TeamMember",
fields=[
(
"id",
models.UUIDField(
default=uuid.uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"role",
models.CharField(
choices=[
("owner", "Owner"),
("admin", "Admin"),
("member", "Member"),
("viewer", "Viewer"),
],
default="member",
max_length=24,
),
),
(
"status",
models.CharField(
choices=[
("active", "Active"),
("invited", "Invited"),
("disabled", "Disabled"),
],
default="active",
max_length=24,
),
),
(
"monthly_credit_limit",
models.DecimalField(decimal_places=2, default=0, max_digits=12),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="members",
to="accounts.team",
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="team_memberships",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"unique_together": {("team", "user")},
},
),
]
@@ -0,0 +1 @@
+59
View File
@@ -0,0 +1,59 @@
import uuid
from django.contrib.auth.models import AbstractUser
from django.db import models
from apps.common.models import TimeStampedModel
class User(AbstractUser):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
phone = models.CharField(max_length=32, blank=True)
avatar_url = models.URLField(blank=True)
@property
def is_disabled(self) -> bool:
return self.status == self.Status.DISABLED
class Team(TimeStampedModel):
class Status(models.TextChoices):
ACTIVE = "active", "Active"
DISABLED = "disabled", "Disabled"
name = models.CharField(max_length=128)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
owner = models.ForeignKey(User, on_delete=models.PROTECT, related_name="owned_teams")
def __str__(self) -> str:
return self.name
class TeamMember(TimeStampedModel):
class Role(models.TextChoices):
OWNER = "owner", "Owner"
ADMIN = "admin", "Admin"
MEMBER = "member", "Member"
VIEWER = "viewer", "Viewer"
class Status(models.TextChoices):
ACTIVE = "active", "Active"
INVITED = "invited", "Invited"
DISABLED = "disabled", "Disabled"
team = models.ForeignKey(Team, on_delete=models.CASCADE, related_name="members")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="team_memberships")
role = models.CharField(max_length=24, choices=Role.choices, default=Role.MEMBER)
status = models.CharField(max_length=24, choices=Status.choices, default=Status.ACTIVE)
monthly_credit_limit = models.DecimalField(max_digits=12, decimal_places=2, default=0)
class Meta:
unique_together = [("team", "user")]
def __str__(self) -> str:
return f"{self.team} / {self.user} / {self.role}"
+68
View File
@@ -0,0 +1,68 @@
from rest_framework import serializers
from apps.billing.models import CreditAccount
from .models import Team, TeamMember, User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ["id", "username", "first_name", "last_name", "email", "phone", "avatar_url", "status"]
read_only_fields = ["id", "status"]
class TeamSerializer(serializers.ModelSerializer):
class Meta:
model = Team
fields = ["id", "name", "status", "owner", "created_at", "updated_at"]
read_only_fields = ["id", "status", "owner", "created_at", "updated_at"]
class TeamMemberSerializer(serializers.ModelSerializer):
user = UserSerializer(read_only=True)
class Meta:
model = TeamMember
fields = ["id", "team", "user", "role", "status", "monthly_credit_limit"]
read_only_fields = ["id", "team", "user", "status"]
class RegisterSerializer(serializers.Serializer):
username = serializers.CharField(max_length=150)
password = serializers.CharField(min_length=8, write_only=True)
email = serializers.EmailField(required=False, allow_blank=True)
team_name = serializers.CharField(max_length=128, required=False, allow_blank=True)
def validate_username(self, value):
if User.objects.filter(username=value).exists():
raise serializers.ValidationError("username already exists")
return value
def create(self, validated_data):
from decimal import Decimal
from django.conf import settings
from django.db import transaction
with transaction.atomic():
user = User.objects.create_user(
username=validated_data["username"],
password=validated_data["password"],
email=validated_data.get("email", ""),
)
team = Team.objects.create(
name=validated_data.get("team_name") or f"{user.username}'s Team",
owner=user,
)
TeamMember.objects.create(team=team, user=user, role=TeamMember.Role.OWNER)
CreditAccount.objects.create(
team=team,
balance=Decimal(str(settings.DEFAULT_TRIAL_CREDITS)),
)
return {"user": user, "team": team}
class LoginSerializer(serializers.Serializer):
username = serializers.CharField()
password = serializers.CharField(write_only=True)
+29
View File
@@ -0,0 +1,29 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.billing.models import CreditAccount
class AuthApiTests(TestCase):
def test_register_creates_user_team_member_credit_account_and_token(self):
client = APIClient()
response = client.post(
"/api/auth/register/",
{
"username": "new-owner",
"password": "strong-password",
"email": "owner@example.com",
"team_name": "Launch Team",
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertIn("token", response.data)
user = User.objects.get(username="new-owner")
team = Team.objects.get(name="Launch Team")
self.assertEqual(team.owner, user)
self.assertTrue(TeamMember.objects.filter(team=team, user=user, role=TeamMember.Role.OWNER).exists())
self.assertTrue(CreditAccount.objects.filter(team=team).exists())
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from .views import login, logout, me, register, team_member_detail, team_member_password, team_members
urlpatterns = [
path("register/", register, name="auth-register"),
path("login/", login, name="auth-login"),
path("logout/", logout, name="auth-logout"),
path("me/", me, name="auth-me"),
path("team/members/", team_members, name="team-members"),
path("team/members/<uuid:member_id>/", team_member_detail, name="team-member-detail"),
path("team/members/<uuid:member_id>/password/", team_member_password, name="team-member-password"),
]
+170
View File
@@ -0,0 +1,170 @@
from django.contrib.auth import authenticate
from django.db import transaction
from rest_framework import status
from rest_framework.authtoken.models import Token
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from apps.common.api import get_current_team
from .models import TeamMember, User
from .serializers import LoginSerializer, RegisterSerializer, TeamMemberSerializer, TeamSerializer, UserSerializer
def auth_payload(user, team, token):
return {
"token": token.key,
"user": UserSerializer(user).data,
"team": TeamSerializer(team).data,
}
@api_view(["POST"])
@permission_classes([])
def register(request):
serializer = RegisterSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.save()
token, _ = Token.objects.get_or_create(user=data["user"])
return Response(auth_payload(data["user"], data["team"], token), status=status.HTTP_201_CREATED)
@api_view(["POST"])
@permission_classes([])
def login(request):
serializer = LoginSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = authenticate(
request,
username=serializer.validated_data["username"],
password=serializer.validated_data["password"],
)
if user is None or user.is_disabled:
return Response({"detail": "invalid credentials"}, status=status.HTTP_400_BAD_REQUEST)
team = get_current_team(user)
token, _ = Token.objects.get_or_create(user=user)
return Response(auth_payload(user, team, token))
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def logout(request):
Token.objects.filter(user=request.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
@api_view(["GET"])
@permission_classes([IsAuthenticated])
def me(request):
team = get_current_team(request.user)
return Response(
{
"user": UserSerializer(request.user).data,
"team": TeamSerializer(team).data,
}
)
def normalize_member_role(role):
if role == "super":
return TeamMember.Role.OWNER
if role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN, TeamMember.Role.MEMBER, TeamMember.Role.VIEWER}:
return role
return TeamMember.Role.MEMBER
def can_manage_team(user, team):
member = TeamMember.objects.filter(team=team, user=user, status=TeamMember.Status.ACTIVE).first()
return bool(member and member.role in {TeamMember.Role.OWNER, TeamMember.Role.ADMIN})
@api_view(["GET", "POST"])
@permission_classes([IsAuthenticated])
def team_members(request):
team = get_current_team(request.user)
if request.method == "GET":
members = TeamMember.objects.filter(team=team).select_related("user").order_by("created_at")
return Response(TeamMemberSerializer(members, many=True).data)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
username = str(request.data.get("username") or "").strip()
password = str(request.data.get("password") or "").strip()
if not username:
return Response({"username": ["This field is required."]}, status=status.HTTP_400_BAD_REQUEST)
if len(password) < 8:
return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST)
if User.objects.filter(username=username).exists():
return Response({"username": ["username already exists"]}, status=status.HTTP_400_BAD_REQUEST)
email = str(request.data.get("email") or "").strip() or f"{username}@airshelf.local"
role = normalize_member_role(request.data.get("role"))
if role == TeamMember.Role.OWNER:
role = TeamMember.Role.ADMIN
with transaction.atomic():
user = User.objects.create_user(username=username, password=password, email=email)
user.first_name = str(request.data.get("name") or "").strip()
user.save(update_fields=["first_name"])
member = TeamMember.objects.create(
team=team,
user=user,
role=role,
monthly_credit_limit=request.data.get("monthly_credit_limit") or request.data.get("monthly") or 0,
)
return Response(TeamMemberSerializer(member).data, status=status.HTTP_201_CREATED)
@api_view(["PATCH", "DELETE"])
@permission_classes([IsAuthenticated])
def team_member_detail(request, member_id):
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first()
if member is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if member.user_id == team.owner_id:
return Response({"detail": "team owner cannot be changed"}, status=status.HTTP_400_BAD_REQUEST)
if request.method == "DELETE":
user = member.user
member.delete()
if not TeamMember.objects.filter(user=user).exists():
user.status = User.Status.DISABLED
user.save(update_fields=["status"])
Token.objects.filter(user=user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)
role = request.data.get("role")
if role:
member.role = normalize_member_role(role)
if member.role == TeamMember.Role.OWNER:
member.role = TeamMember.Role.ADMIN
if "monthly_credit_limit" in request.data or "monthly" in request.data:
member.monthly_credit_limit = request.data.get("monthly_credit_limit", request.data.get("monthly")) or 0
name = str(request.data.get("name") or "").strip()
if name:
member.user.first_name = name
member.user.save(update_fields=["first_name"])
member.save(update_fields=["role", "monthly_credit_limit", "updated_at"])
return Response(TeamMemberSerializer(member).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def team_member_password(request, member_id):
team = get_current_team(request.user)
if not can_manage_team(request.user, team):
return Response({"detail": "permission denied"}, status=status.HTTP_403_FORBIDDEN)
member = TeamMember.objects.select_related("user").filter(team=team, id=member_id).first()
if member is None:
return Response({"detail": "not found"}, status=status.HTTP_404_NOT_FOUND)
if member.user_id == team.owner_id:
return Response({"detail": "team owner password cannot be reset here"}, status=status.HTTP_400_BAD_REQUEST)
password = str(request.data.get("password") or "").strip()
if len(password) < 8:
return Response({"password": ["Ensure this field has at least 8 characters."]}, status=status.HTTP_400_BAD_REQUEST)
member.user.set_password(password)
member.user.save(update_fields=["password"])
Token.objects.filter(user=member.user).delete()
return Response(status=status.HTTP_204_NO_CONTENT)