feat:精简
Some checks failed
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Python CI / Format Backend (3.11.x) (push) Has been cancelled
Python CI / Format Backend (3.12.x) (push) Has been cancelled
Frontend Build / Format & Build Frontend (push) Has been cancelled
Frontend Build / Frontend Unit Tests (push) Has been cancelled
Create and publish Docker images with specific build args / merge-main-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda126-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-ollama-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-slim-images (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
Some checks failed
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Python CI / Format Backend (3.11.x) (push) Has been cancelled
Python CI / Format Backend (3.12.x) (push) Has been cancelled
Frontend Build / Format & Build Frontend (push) Has been cancelled
Frontend Build / Frontend Unit Tests (push) Has been cancelled
Create and publish Docker images with specific build args / merge-main-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda126-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-ollama-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-slim-images (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled
This commit is contained in:
@@ -72,6 +72,7 @@ class SignupForm(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
profile_image_url: Optional[str] = "/user.png"
|
||||
email_code: Optional[str] = None
|
||||
|
||||
|
||||
class AddUserForm(SignupForm):
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
# credits model - deprecated, kept for backward compatibility
|
||||
# this module is replaced by the subscription system
|
||||
|
||||
from decimal import Decimal
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SetCreditFormDetail(BaseModel):
|
||||
operator: str = ""
|
||||
api_name: str = ""
|
||||
api_params: dict = {}
|
||||
|
||||
|
||||
class SetCreditForm(BaseModel):
|
||||
user_id: str
|
||||
credit: Decimal = Decimal("0")
|
||||
detail: Optional[SetCreditFormDetail] = None
|
||||
|
||||
|
||||
class AddCreditForm(BaseModel):
|
||||
user_id: str
|
||||
amount: Decimal = Decimal("0")
|
||||
detail: Optional[SetCreditFormDetail] = None
|
||||
|
||||
|
||||
class CreditModel(BaseModel):
|
||||
user_id: str
|
||||
credit: Decimal = Decimal("0")
|
||||
|
||||
|
||||
class Credits:
|
||||
"""
|
||||
Deprecated credits class.
|
||||
Kept for backward compatibility - returns dummy data.
|
||||
The subscription system now handles billing.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def init_credit_by_user_id(cls, user_id: str) -> CreditModel:
|
||||
return CreditModel(user_id=user_id, credit=Decimal("0"))
|
||||
|
||||
@classmethod
|
||||
def get_credit_by_user_id(cls, user_id: str) -> Optional[CreditModel]:
|
||||
return CreditModel(user_id=user_id, credit=Decimal("0"))
|
||||
|
||||
@classmethod
|
||||
def set_credit_by_user_id(cls, form_data: SetCreditForm) -> CreditModel:
|
||||
return CreditModel(user_id=form_data.user_id, credit=form_data.credit)
|
||||
|
||||
@classmethod
|
||||
def add_credit_by_user_id(cls, form_data: AddCreditForm) -> CreditModel:
|
||||
return CreditModel(user_id=form_data.user_id, credit=form_data.amount)
|
||||
|
||||
@classmethod
|
||||
def list_credits_by_user_id(cls, user_ids: List[str]) -> List[CreditModel]:
|
||||
return [CreditModel(user_id=uid, credit=Decimal("0")) for uid in user_ids]
|
||||
@@ -1,394 +0,0 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Optional
|
||||
from functools import lru_cache
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
from open_webui.models.groups import Groups
|
||||
from open_webui.utils.access_control import has_access
|
||||
from open_webui.models.users import User, UserModel, Users, UserResponse
|
||||
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import BigInteger, Boolean, Column, String, Text, JSON
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
|
||||
from sqlalchemy import or_, func, select, and_, text, cast, or_, and_, func
|
||||
from sqlalchemy.sql import exists
|
||||
|
||||
####################
|
||||
# Note DB Schema
|
||||
####################
|
||||
|
||||
|
||||
class Note(Base):
|
||||
__tablename__ = "note"
|
||||
|
||||
id = Column(Text, primary_key=True, unique=True)
|
||||
user_id = Column(Text)
|
||||
|
||||
title = Column(Text)
|
||||
data = Column(JSON, nullable=True)
|
||||
meta = Column(JSON, nullable=True)
|
||||
|
||||
access_control = Column(JSON, nullable=True)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
updated_at = Column(BigInteger)
|
||||
|
||||
|
||||
class NoteModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
user_id: str
|
||||
|
||||
title: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
created_at: int # timestamp in epoch
|
||||
updated_at: int # timestamp in epoch
|
||||
|
||||
|
||||
####################
|
||||
# Forms
|
||||
####################
|
||||
|
||||
|
||||
class NoteForm(BaseModel):
|
||||
title: str
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class NoteUpdateForm(BaseModel):
|
||||
title: Optional[str] = None
|
||||
data: Optional[dict] = None
|
||||
meta: Optional[dict] = None
|
||||
access_control: Optional[dict] = None
|
||||
|
||||
|
||||
class NoteUserResponse(NoteModel):
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class NoteItemResponse(BaseModel):
|
||||
id: str
|
||||
title: str
|
||||
data: Optional[dict]
|
||||
updated_at: int
|
||||
created_at: int
|
||||
user: Optional[UserResponse] = None
|
||||
|
||||
|
||||
class NoteListResponse(BaseModel):
|
||||
items: list[NoteUserResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class NoteTable:
|
||||
def _has_permission(self, db, query, filter: dict, permission: str = "read"):
|
||||
group_ids = filter.get("group_ids", [])
|
||||
user_id = filter.get("user_id")
|
||||
dialect_name = db.bind.dialect.name
|
||||
|
||||
conditions = []
|
||||
|
||||
# Handle read_only permission separately
|
||||
if permission == "read_only":
|
||||
# For read_only, we want items where:
|
||||
# 1. User has explicit read permission (via groups or user-level)
|
||||
# 2. BUT does NOT have write permission
|
||||
# 3. Public items are NOT considered read_only
|
||||
|
||||
read_conditions = []
|
||||
|
||||
# Group-level read permission
|
||||
if group_ids:
|
||||
group_read_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_read_conditions.append(
|
||||
Note.access_control["read"]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_read_conditions.append(
|
||||
cast(
|
||||
Note.access_control["read"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_read_conditions:
|
||||
read_conditions.append(or_(*group_read_conditions))
|
||||
|
||||
# Combine read conditions
|
||||
if read_conditions:
|
||||
has_read = or_(*read_conditions)
|
||||
else:
|
||||
# If no read conditions, return empty result
|
||||
return query.filter(False)
|
||||
|
||||
# Now exclude items where user has write permission
|
||||
write_exclusions = []
|
||||
|
||||
# Exclude items owned by user (they have implicit write)
|
||||
if user_id:
|
||||
write_exclusions.append(Note.user_id != user_id)
|
||||
|
||||
# Exclude items where user has explicit write permission via groups
|
||||
if group_ids:
|
||||
group_write_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_write_conditions.append(
|
||||
Note.access_control["write"]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_write_conditions.append(
|
||||
cast(
|
||||
Note.access_control["write"]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
|
||||
if group_write_conditions:
|
||||
# User should NOT have write permission
|
||||
write_exclusions.append(~or_(*group_write_conditions))
|
||||
|
||||
# Exclude public items (items without access_control)
|
||||
write_exclusions.append(Note.access_control.isnot(None))
|
||||
write_exclusions.append(cast(Note.access_control, String) != "null")
|
||||
|
||||
# Combine: has read AND does not have write AND not public
|
||||
if write_exclusions:
|
||||
query = query.filter(and_(has_read, *write_exclusions))
|
||||
else:
|
||||
query = query.filter(has_read)
|
||||
|
||||
return query
|
||||
|
||||
# Original logic for other permissions (read, write, etc.)
|
||||
# Public access conditions
|
||||
if group_ids or user_id:
|
||||
conditions.extend(
|
||||
[
|
||||
Note.access_control.is_(None),
|
||||
cast(Note.access_control, String) == "null",
|
||||
]
|
||||
)
|
||||
|
||||
# User-level permission (owner has all permissions)
|
||||
if user_id:
|
||||
conditions.append(Note.user_id == user_id)
|
||||
|
||||
# Group-level permission
|
||||
if group_ids:
|
||||
group_conditions = []
|
||||
for gid in group_ids:
|
||||
if dialect_name == "sqlite":
|
||||
group_conditions.append(
|
||||
Note.access_control[permission]["group_ids"].contains([gid])
|
||||
)
|
||||
elif dialect_name == "postgresql":
|
||||
group_conditions.append(
|
||||
cast(
|
||||
Note.access_control[permission]["group_ids"],
|
||||
JSONB,
|
||||
).contains([gid])
|
||||
)
|
||||
conditions.append(or_(*group_conditions))
|
||||
|
||||
if conditions:
|
||||
query = query.filter(or_(*conditions))
|
||||
|
||||
return query
|
||||
|
||||
def insert_new_note(
|
||||
self,
|
||||
form_data: NoteForm,
|
||||
user_id: str,
|
||||
) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
note = NoteModel(
|
||||
**{
|
||||
"id": str(uuid.uuid4()),
|
||||
"user_id": user_id,
|
||||
**form_data.model_dump(),
|
||||
"created_at": int(time.time_ns()),
|
||||
"updated_at": int(time.time_ns()),
|
||||
}
|
||||
)
|
||||
|
||||
new_note = Note(**note.model_dump())
|
||||
|
||||
db.add(new_note)
|
||||
db.commit()
|
||||
return note
|
||||
|
||||
def get_notes(
|
||||
self, skip: Optional[int] = None, limit: Optional[int] = None
|
||||
) -> list[NoteModel]:
|
||||
with get_db() as db:
|
||||
query = db.query(Note).order_by(Note.updated_at.desc())
|
||||
if skip is not None:
|
||||
query = query.offset(skip)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def search_notes(
|
||||
self, user_id: str, filter: dict = {}, skip: int = 0, limit: int = 30
|
||||
) -> NoteListResponse:
|
||||
with get_db() as db:
|
||||
query = db.query(Note, User).outerjoin(User, User.id == Note.user_id)
|
||||
if filter:
|
||||
query_key = filter.get("query")
|
||||
if query_key:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Note.title.ilike(f"%{query_key}%"),
|
||||
cast(Note.data["content"]["md"], Text).ilike(
|
||||
f"%{query_key}%"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
view_option = filter.get("view_option")
|
||||
if view_option == "created":
|
||||
query = query.filter(Note.user_id == user_id)
|
||||
elif view_option == "shared":
|
||||
query = query.filter(Note.user_id != user_id)
|
||||
|
||||
# Apply access control filtering
|
||||
if "permission" in filter:
|
||||
permission = filter["permission"]
|
||||
else:
|
||||
permission = "write"
|
||||
|
||||
query = self._has_permission(
|
||||
db,
|
||||
query,
|
||||
filter,
|
||||
permission=permission,
|
||||
)
|
||||
|
||||
order_by = filter.get("order_by")
|
||||
direction = filter.get("direction")
|
||||
|
||||
if order_by == "name":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Note.title.asc())
|
||||
else:
|
||||
query = query.order_by(Note.title.desc())
|
||||
elif order_by == "created_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Note.created_at.asc())
|
||||
else:
|
||||
query = query.order_by(Note.created_at.desc())
|
||||
elif order_by == "updated_at":
|
||||
if direction == "asc":
|
||||
query = query.order_by(Note.updated_at.asc())
|
||||
else:
|
||||
query = query.order_by(Note.updated_at.desc())
|
||||
else:
|
||||
query = query.order_by(Note.updated_at.desc())
|
||||
|
||||
else:
|
||||
query = query.order_by(Note.updated_at.desc())
|
||||
|
||||
# Count BEFORE pagination
|
||||
total = query.count()
|
||||
|
||||
if skip:
|
||||
query = query.offset(skip)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
|
||||
items = query.all()
|
||||
|
||||
notes = []
|
||||
for note, user in items:
|
||||
notes.append(
|
||||
NoteUserResponse(
|
||||
**NoteModel.model_validate(note).model_dump(),
|
||||
user=(
|
||||
UserResponse(**UserModel.model_validate(user).model_dump())
|
||||
if user
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
return NoteListResponse(items=notes, total=total)
|
||||
|
||||
def get_notes_by_user_id(
|
||||
self,
|
||||
user_id: str,
|
||||
permission: str = "read",
|
||||
skip: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> list[NoteModel]:
|
||||
with get_db() as db:
|
||||
user_group_ids = [
|
||||
group.id for group in Groups.get_groups_by_member_id(user_id)
|
||||
]
|
||||
|
||||
query = db.query(Note).order_by(Note.updated_at.desc())
|
||||
query = self._has_permission(
|
||||
db, query, {"user_id": user_id, "group_ids": user_group_ids}, permission
|
||||
)
|
||||
|
||||
if skip is not None:
|
||||
query = query.offset(skip)
|
||||
if limit is not None:
|
||||
query = query.limit(limit)
|
||||
|
||||
notes = query.all()
|
||||
return [NoteModel.model_validate(note) for note in notes]
|
||||
|
||||
def get_note_by_id(self, id: str) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
note = db.query(Note).filter(Note.id == id).first()
|
||||
return NoteModel.model_validate(note) if note else None
|
||||
|
||||
def update_note_by_id(
|
||||
self, id: str, form_data: NoteUpdateForm
|
||||
) -> Optional[NoteModel]:
|
||||
with get_db() as db:
|
||||
note = db.query(Note).filter(Note.id == id).first()
|
||||
if not note:
|
||||
return None
|
||||
|
||||
form_data = form_data.model_dump(exclude_unset=True)
|
||||
|
||||
if "title" in form_data:
|
||||
note.title = form_data["title"]
|
||||
if "data" in form_data:
|
||||
note.data = {**note.data, **form_data["data"]}
|
||||
if "meta" in form_data:
|
||||
note.meta = {**note.meta, **form_data["meta"]}
|
||||
|
||||
if "access_control" in form_data:
|
||||
note.access_control = form_data["access_control"]
|
||||
|
||||
note.updated_at = int(time.time_ns())
|
||||
|
||||
db.commit()
|
||||
return NoteModel.model_validate(note) if note else None
|
||||
|
||||
def delete_note_by_id(self, id: str):
|
||||
with get_db() as db:
|
||||
db.query(Note).filter(Note.id == id).delete()
|
||||
db.commit()
|
||||
return True
|
||||
|
||||
|
||||
Notes = NoteTable()
|
||||
@@ -1,9 +1,10 @@
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import List, Optional, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from sqlalchemy import JSON, BigInteger, Boolean, Column, Integer, Numeric, String, Text
|
||||
|
||||
from open_webui.internal.db import Base, get_db
|
||||
@@ -21,8 +22,19 @@ class SubscriptionPlan(Base):
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
price = Column(Numeric(precision=10, scale=2), default=0)
|
||||
|
||||
# per-model monthly limits: {"model_id": limit, ...}
|
||||
# -1 = unlimited, 0 = not allowed, positive = monthly limit
|
||||
model_limits = Column(JSON, nullable=True)
|
||||
|
||||
# default limit for models not in model_limits
|
||||
# -1 = unlimited, 0 = not allowed, positive = monthly limit
|
||||
default_model_limit = Column(Integer, default=0)
|
||||
|
||||
# deprecated fields (kept for backward compatibility)
|
||||
monthly_message_limit = Column(Integer, nullable=True)
|
||||
allowed_models = Column(JSON, nullable=True)
|
||||
|
||||
priority = Column(Integer, default=0)
|
||||
is_default = Column(Boolean, default=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
@@ -42,6 +54,11 @@ class UserSubscription(Base):
|
||||
|
||||
current_period_start = Column(BigInteger)
|
||||
current_period_end = Column(BigInteger)
|
||||
|
||||
# per-model usage tracking: {"model_id": count, ...}
|
||||
model_usage = Column(JSON, default={})
|
||||
|
||||
# deprecated field (kept for backward compatibility)
|
||||
messages_used = Column(Integer, default=0)
|
||||
|
||||
created_at = Column(BigInteger)
|
||||
@@ -72,7 +89,7 @@ class RedemptionCode(Base):
|
||||
redemption_type = Column(String, default="duration")
|
||||
plan_id = Column(String, nullable=True)
|
||||
duration_days = Column(Integer, nullable=True)
|
||||
upgrade_expires_at = Column(BigInteger, nullable=True)
|
||||
upgrade_days = Column(Integer, nullable=True)
|
||||
|
||||
user_id = Column(String, index=True, nullable=True)
|
||||
created_at = Column(BigInteger, index=True)
|
||||
@@ -85,6 +102,16 @@ class RedemptionCode(Base):
|
||||
####################
|
||||
|
||||
|
||||
def _parse_json_field(v):
|
||||
"""Parse JSON field from string if needed."""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
return json.loads(v)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
class SubscriptionPlanModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -92,22 +119,43 @@ class SubscriptionPlanModel(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: Decimal = Field(default_factory=lambda: Decimal("0"))
|
||||
|
||||
# per-model monthly limits
|
||||
model_limits: Optional[Dict[str, int]] = None
|
||||
default_model_limit: int = 0
|
||||
|
||||
# deprecated fields
|
||||
monthly_message_limit: Optional[int] = None
|
||||
allowed_models: Optional[List[str]] = None
|
||||
|
||||
priority: int = 0
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
updated_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
|
||||
@field_validator('model_limits', 'allowed_models', mode='before')
|
||||
@classmethod
|
||||
def parse_json_fields(cls, v):
|
||||
return _parse_json_field(v)
|
||||
|
||||
def get_model_limit(self, model_id: str) -> int:
|
||||
"""
|
||||
Get the usage limit for a specific model.
|
||||
Returns: -1 = unlimited, 0 = not allowed, positive = monthly limit
|
||||
"""
|
||||
if self.model_limits and model_id in self.model_limits:
|
||||
return self.model_limits[model_id]
|
||||
return self.default_model_limit
|
||||
|
||||
|
||||
class SubscriptionPlanForm(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
price: float = 0
|
||||
monthly_message_limit: Optional[int] = None
|
||||
allowed_models: Optional[List[str]] = None
|
||||
model_limits: Optional[Dict[str, int]] = None
|
||||
default_model_limit: int = 0
|
||||
priority: int = 0
|
||||
is_default: bool = False
|
||||
is_active: bool = True
|
||||
@@ -117,8 +165,8 @@ class UpdateSubscriptionPlanForm(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
monthly_message_limit: Optional[int] = None
|
||||
allowed_models: Optional[List[str]] = None
|
||||
model_limits: Optional[Dict[str, int]] = None
|
||||
default_model_limit: Optional[int] = None
|
||||
priority: Optional[int] = None
|
||||
is_default: Optional[bool] = None
|
||||
is_active: Optional[bool] = None
|
||||
@@ -134,16 +182,48 @@ class UserSubscriptionModel(BaseModel):
|
||||
expires_at: Optional[int] = None
|
||||
current_period_start: int
|
||||
current_period_end: int
|
||||
|
||||
# per-model usage tracking
|
||||
model_usage: Dict[str, int] = Field(default_factory=dict)
|
||||
|
||||
# deprecated field
|
||||
messages_used: int = 0
|
||||
|
||||
created_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
updated_at: int = Field(default_factory=lambda: int(time.time()))
|
||||
|
||||
@field_validator('model_usage', mode='before')
|
||||
@classmethod
|
||||
def parse_model_usage(cls, v):
|
||||
parsed = _parse_json_field(v)
|
||||
return parsed if parsed else {}
|
||||
|
||||
def get_model_usage(self, model_id: str) -> int:
|
||||
"""Get usage count for a specific model."""
|
||||
return self.model_usage.get(model_id, 0)
|
||||
|
||||
|
||||
class ModelUsageInfo(BaseModel):
|
||||
"""Usage info for a single model."""
|
||||
model_id: str
|
||||
limit: int # -1 = unlimited, 0 = not allowed
|
||||
used: int
|
||||
remaining: Optional[int] = None # None if unlimited
|
||||
|
||||
|
||||
class UserSubscriptionWithPlanModel(UserSubscriptionModel):
|
||||
plan: Optional[SubscriptionPlanModel] = None
|
||||
messages_remaining: Optional[int] = None
|
||||
days_remaining: Optional[int] = None
|
||||
|
||||
# per-model usage summary
|
||||
model_usage_info: Optional[List[ModelUsageInfo]] = None
|
||||
|
||||
# deprecated
|
||||
messages_remaining: Optional[int] = None
|
||||
|
||||
# admin view fields
|
||||
username: Optional[str] = None
|
||||
|
||||
|
||||
class SubscriptionUsageLogModel(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
@@ -166,7 +246,7 @@ class RedemptionCodeModel(BaseModel):
|
||||
redemption_type: str = "duration"
|
||||
plan_id: Optional[str] = None
|
||||
duration_days: Optional[int] = None
|
||||
upgrade_expires_at: Optional[int] = None
|
||||
upgrade_days: Optional[int] = None
|
||||
user_id: Optional[str] = None
|
||||
created_at: int
|
||||
expired_at: Optional[int] = None
|
||||
@@ -179,7 +259,7 @@ class CreateRedemptionCodeForm(BaseModel):
|
||||
redemption_type: str = "duration"
|
||||
plan_id: str
|
||||
duration_days: Optional[int] = Field(default=None, ge=1)
|
||||
upgrade_expires_at: Optional[int] = Field(default=None, gt=0)
|
||||
upgrade_days: Optional[int] = Field(default=None, ge=1)
|
||||
expired_at: Optional[int] = Field(default=None, gt=0)
|
||||
|
||||
|
||||
@@ -187,7 +267,7 @@ class UpdateRedemptionCodeForm(BaseModel):
|
||||
purpose: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
plan_id: Optional[str] = None
|
||||
duration_days: Optional[int] = Field(None, ge=1)
|
||||
upgrade_expires_at: Optional[int] = Field(None, gt=0)
|
||||
upgrade_days: Optional[int] = Field(None, ge=1)
|
||||
expired_at: Optional[int] = Field(None, gt=0)
|
||||
|
||||
|
||||
@@ -231,8 +311,8 @@ class SubscriptionPlansTable:
|
||||
name=form_data.name,
|
||||
description=form_data.description,
|
||||
price=Decimal(str(form_data.price)),
|
||||
monthly_message_limit=form_data.monthly_message_limit,
|
||||
allowed_models=form_data.allowed_models,
|
||||
model_limits=form_data.model_limits,
|
||||
default_model_limit=form_data.default_model_limit,
|
||||
priority=form_data.priority,
|
||||
is_default=form_data.is_default,
|
||||
is_active=form_data.is_active,
|
||||
@@ -302,19 +382,28 @@ class UserSubscriptionsTable:
|
||||
plan = SubscriptionPlans.get_plan_by_id(sub.plan_id)
|
||||
now = int(time.time())
|
||||
|
||||
messages_remaining = None
|
||||
if plan and plan.monthly_message_limit:
|
||||
messages_remaining = max(0, plan.monthly_message_limit - sub.messages_used)
|
||||
|
||||
days_remaining = None
|
||||
if sub.expires_at:
|
||||
days_remaining = max(0, (sub.expires_at - now) // 86400)
|
||||
|
||||
# build model usage info
|
||||
model_usage_info = []
|
||||
if plan and plan.model_limits:
|
||||
for model_id, limit in plan.model_limits.items():
|
||||
used = sub.get_model_usage(model_id)
|
||||
remaining = None if limit == -1 else max(0, limit - used)
|
||||
model_usage_info.append(ModelUsageInfo(
|
||||
model_id=model_id,
|
||||
limit=limit,
|
||||
used=used,
|
||||
remaining=remaining,
|
||||
))
|
||||
|
||||
return UserSubscriptionWithPlanModel(
|
||||
**sub.model_dump(),
|
||||
plan=plan,
|
||||
messages_remaining=messages_remaining,
|
||||
days_remaining=days_remaining,
|
||||
model_usage_info=model_usage_info if model_usage_info else None,
|
||||
)
|
||||
|
||||
def get_all_subscriptions(
|
||||
@@ -338,10 +427,6 @@ class UserSubscriptionsTable:
|
||||
plan = SubscriptionPlans.get_plan_by_id(sub_model.plan_id)
|
||||
now = int(time.time())
|
||||
|
||||
messages_remaining = None
|
||||
if plan and plan.monthly_message_limit:
|
||||
messages_remaining = max(0, plan.monthly_message_limit - sub_model.messages_used)
|
||||
|
||||
days_remaining = None
|
||||
if sub_model.expires_at:
|
||||
days_remaining = max(0, (sub_model.expires_at - now) // 86400)
|
||||
@@ -349,7 +434,6 @@ class UserSubscriptionsTable:
|
||||
results.append(UserSubscriptionWithPlanModel(
|
||||
**sub_model.model_dump(),
|
||||
plan=plan,
|
||||
messages_remaining=messages_remaining,
|
||||
days_remaining=days_remaining,
|
||||
))
|
||||
|
||||
@@ -361,11 +445,11 @@ class UserSubscriptionsTable:
|
||||
# create a default free plan if not exists
|
||||
default_plan = SubscriptionPlans.create_plan(SubscriptionPlanForm(
|
||||
id="free",
|
||||
name="Free",
|
||||
description="Default free plan",
|
||||
name="免费版",
|
||||
description="免费套餐,基础模型无限使用",
|
||||
price=0,
|
||||
monthly_message_limit=100,
|
||||
allowed_models=[],
|
||||
model_limits={},
|
||||
default_model_limit=-1, # all models unlimited by default
|
||||
priority=0,
|
||||
is_default=True,
|
||||
is_active=True,
|
||||
@@ -389,6 +473,7 @@ class UserSubscriptionsTable:
|
||||
expires_at=None,
|
||||
current_period_start=now,
|
||||
current_period_end=period_end,
|
||||
model_usage={},
|
||||
messages_used=0,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
@@ -507,6 +592,7 @@ class UserSubscriptionsTable:
|
||||
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
|
||||
"current_period_start": now,
|
||||
"current_period_end": period_end,
|
||||
"model_usage": {},
|
||||
"messages_used": 0,
|
||||
"updated_at": now,
|
||||
})
|
||||
@@ -515,7 +601,31 @@ class UserSubscriptionsTable:
|
||||
sub = db.query(UserSubscription).filter(UserSubscription.id == subscription_id).first()
|
||||
return UserSubscriptionModel.model_validate(sub)
|
||||
|
||||
def increment_model_usage(self, subscription_id: str, model_id: str) -> None:
|
||||
"""Increment usage counter for a specific model."""
|
||||
with get_db() as db:
|
||||
sub = db.query(UserSubscription).filter(UserSubscription.id == subscription_id).first()
|
||||
if not sub:
|
||||
return
|
||||
|
||||
current_usage = sub.model_usage or {}
|
||||
if isinstance(current_usage, str):
|
||||
try:
|
||||
current_usage = json.loads(current_usage)
|
||||
except json.JSONDecodeError:
|
||||
current_usage = {}
|
||||
|
||||
current_usage[model_id] = current_usage.get(model_id, 0) + 1
|
||||
|
||||
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
|
||||
"model_usage": current_usage,
|
||||
"messages_used": UserSubscription.messages_used + 1,
|
||||
"updated_at": int(time.time()),
|
||||
})
|
||||
db.commit()
|
||||
|
||||
def increment_usage(self, subscription_id: str) -> None:
|
||||
"""Deprecated: use increment_model_usage instead."""
|
||||
with get_db() as db:
|
||||
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
|
||||
"messages_used": UserSubscription.messages_used + 1,
|
||||
@@ -659,10 +769,11 @@ class RedemptionCodesTable:
|
||||
duration_days=redemption_code.duration_days,
|
||||
)
|
||||
else:
|
||||
# upgrade type: calculate expires_at from upgrade_days
|
||||
return UserSubscriptions.upgrade_subscription(
|
||||
user_id=user_id,
|
||||
plan_id=redemption_code.plan_id,
|
||||
expires_at=redemption_code.upgrade_expires_at,
|
||||
duration_days=redemption_code.upgrade_days,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user