feat: add billing core models
This commit is contained in:
+53
-1
@@ -1,7 +1,7 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
|
||||
from .models import User
|
||||
from .models import ApiKey, User, UserWallet
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
@@ -20,3 +20,55 @@ class UserAdmin(DjangoUserAdmin):
|
||||
)
|
||||
list_filter = DjangoUserAdmin.list_filter + ("status",)
|
||||
search_fields = ("username", "email", "payment_user_id")
|
||||
|
||||
|
||||
@admin.register(UserWallet)
|
||||
class UserWalletAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "points_balance", "updated_at")
|
||||
search_fields = ("user__username", "user__email")
|
||||
readonly_fields = ("user", "points_balance", "created_at", "updated_at")
|
||||
ordering = ("user__username",)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
@admin.register(ApiKey)
|
||||
class ApiKeyAdmin(admin.ModelAdmin):
|
||||
list_display = ("key_prefix", "user", "name", "status", "last_used_at", "created_at")
|
||||
list_filter = ("status", "created_at")
|
||||
search_fields = ("key_prefix", "user__username", "user__email", "name")
|
||||
readonly_fields = (
|
||||
"user",
|
||||
"key_prefix",
|
||||
"key_hash_masked",
|
||||
"last_used_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
fields = (
|
||||
"user",
|
||||
"name",
|
||||
"status",
|
||||
"key_prefix",
|
||||
"key_hash_masked",
|
||||
"last_used_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
ordering = ("-created_at", "-id")
|
||||
|
||||
@admin.display(description="key hash")
|
||||
def key_hash_masked(self, obj):
|
||||
if not obj.key_hash:
|
||||
return ""
|
||||
return f"{obj.key_hash[:8]}...{obj.key_hash[-6:]}"
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-02 06:52
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('users', '0002_alter_user_email'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ApiKey',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(blank=True, max_length=80)),
|
||||
('key_hash', models.CharField(editable=False, max_length=64, unique=True)),
|
||||
('key_prefix', models.CharField(editable=False, max_length=32)),
|
||||
('status', models.CharField(choices=[('active', 'Active'), ('revoked', 'Revoked')], default='active', max_length=20)),
|
||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='api_keys', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'api_key',
|
||||
'ordering': ('-created_at', '-id'),
|
||||
'indexes': [models.Index(fields=['user', 'status'], name='api_key_user_id_b1527f_idx'), models.Index(fields=['key_prefix'], name='api_key_key_pre_e8448b_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UserWallet',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('points_balance', models.BigIntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='wallet', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'user_wallet',
|
||||
'ordering': ('user_id',),
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('points_balance__gte', 0)), name='user_wallet_points_balance_non_negative')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
@@ -26,3 +34,94 @@ class User(AbstractUser):
|
||||
|
||||
class Meta:
|
||||
db_table = "user"
|
||||
|
||||
|
||||
class UserWallet(models.Model):
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="wallet",
|
||||
)
|
||||
points_balance = models.BigIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "user_wallet"
|
||||
ordering = ("user_id",)
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
condition=Q(points_balance__gte=0),
|
||||
name="user_wallet_points_balance_non_negative",
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} wallet: {self.points_balance}"
|
||||
|
||||
|
||||
class ApiKey(models.Model):
|
||||
KEY_PREFIX_LENGTH = 16
|
||||
|
||||
class Status(models.TextChoices):
|
||||
ACTIVE = "active", "Active"
|
||||
REVOKED = "revoked", "Revoked"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.PROTECT,
|
||||
related_name="api_keys",
|
||||
)
|
||||
name = models.CharField(max_length=80, blank=True)
|
||||
key_hash = models.CharField(max_length=64, unique=True, editable=False)
|
||||
key_prefix = models.CharField(max_length=32, editable=False)
|
||||
status = models.CharField(
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.ACTIVE,
|
||||
)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "api_key"
|
||||
ordering = ("-created_at", "-id")
|
||||
indexes = [
|
||||
models.Index(fields=("user", "status")),
|
||||
models.Index(fields=("key_prefix",)),
|
||||
]
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.user} {self.key_prefix}"
|
||||
|
||||
@classmethod
|
||||
def generate_plaintext_key(cls) -> str:
|
||||
return f"sk_cmhub_{secrets.token_urlsafe(32)}"
|
||||
|
||||
@classmethod
|
||||
def hash_key(cls, raw_key: str) -> str:
|
||||
return hashlib.sha256(raw_key.encode("utf-8")).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def build_prefix(cls, raw_key: str) -> str:
|
||||
return raw_key[: cls.KEY_PREFIX_LENGTH]
|
||||
|
||||
@classmethod
|
||||
def create_for_user(cls, user, *, name: str = "") -> tuple["ApiKey", str]:
|
||||
raw_key = cls.generate_plaintext_key()
|
||||
api_key = cls(user=user, name=name)
|
||||
api_key.set_key(raw_key)
|
||||
api_key.save()
|
||||
return api_key, raw_key
|
||||
|
||||
def set_key(self, raw_key: str) -> None:
|
||||
self.key_hash = self.hash_key(raw_key)
|
||||
self.key_prefix = self.build_prefix(raw_key)
|
||||
|
||||
def matches_key(self, raw_key: str) -> bool:
|
||||
return hmac.compare_digest(self.key_hash, self.hash_key(raw_key))
|
||||
|
||||
@property
|
||||
def is_active_key(self) -> bool:
|
||||
return self.status == self.Status.ACTIVE
|
||||
|
||||
Reference in New Issue
Block a user