feat: implement usage limits across the app

This commit is contained in:
swve 2024-08-11 21:08:18 +02:00
parent 85ffb44d93
commit a5fbf49304
15 changed files with 281 additions and 160 deletions

View file

@ -30,7 +30,7 @@ def migrate_v0_to_v1(v0_config):
),
},
"usergroups": {
"enabled": True,
"enabled": False,
"limit": (
v0_config["GeneralConfig"]["limits"]["max_staff"]
if v0_config["GeneralConfig"]["limits"]["limits_enabled"]
@ -46,7 +46,7 @@ def migrate_v0_to_v1(v0_config):
),
},
"ai": {
"enabled": v0_config["AIConfig"]["enabled"],
"enabled": False,
"limit": (
v0_config["AIConfig"]["limits"]["max_asks"]
if v0_config["AIConfig"]["limits"]["limits_enabled"]
@ -54,12 +54,12 @@ def migrate_v0_to_v1(v0_config):
),
"model": 'gpt-4o-mini',
},
"assignments": {"enabled": True, "limit": 10},
"assignments": {"enabled": True, "limit": 5},
"payments": {"enabled": False, "stripe_key": ""},
"discussions": {"enabled": False, "limit": 10},
"analytics": {"enabled": False, "limit": 10},
"collaboration": {
"enabled": v0_config["GeneralConfig"]["collaboration"],
"enabled": False,
"limit": 10,
},
"api": {"enabled": False, "limit": 10},

View file

@ -92,17 +92,17 @@ async def api_update_activity(
)
@router.delete("/{activity_id}")
@router.delete("/{activity_uuid}")
async def api_delete_activity(
request: Request,
activity_id: str,
activity_uuid: str,
current_user: PublicUser = Depends(get_current_user),
db_session=Depends(get_db_session),
):
"""
Delete activity by activity_id
"""
return await delete_activity(request, activity_id, current_user, db_session)
return await delete_activity(request, activity_uuid, current_user, db_session)
# Video activity

View file

@ -0,0 +1,141 @@
import redis
from src.db.organization_config import OrganizationConfig
from config.config import get_learnhouse_config
from typing import Literal, TypeAlias
from fastapi import HTTPException
from sqlmodel import Session, select
FeatureSet: TypeAlias = Literal[
"ai",
"analytics",
"api",
"assignments",
"collaboration",
"courses",
"discussions",
"members",
"payments",
"storage",
"usergroups",
]
def check_limits_with_usage(
feature: FeatureSet,
org_id: int,
db_session: Session,
):
# Get the Organization Config
statement = select(OrganizationConfig).where(OrganizationConfig.org_id == org_id)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
raise HTTPException(
status_code=404,
detail="Organization has no config",
)
# Check if the Organizations has AI enabled
if org_config.config["features"][feature]["enabled"] == False:
raise HTTPException(
status_code=403,
detail=f"{feature.capitalize()} is not enabled for this organization",
)
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
# Check limits
feature_limit = org_config.config["features"][feature]["limit"]
if feature_limit > 0:
# Get the number of feature usage
feature_usage = r.get(f"{feature}_usage:{org_id}")
# Get a number of feature asks
if feature_usage is None:
feature_usage_count = 0
else:
feature_usage_count = int(feature_usage) # type: ignore
# Check if the Number of usage is less than the max_asks limit
if feature_limit <= feature_usage_count:
raise HTTPException(
status_code=403,
detail=f"Usage Limit has been reached for {feature.capitalize()}",
)
return True
def increase_feature_usage(
feature: FeatureSet,
org_id: int,
db_session: Session,
):
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
# Get the number of feature usage
feature_usage = r.get(f"{feature}_usage:{org_id}")
# Get a number of feature asks
if feature_usage is None:
feature_usage_count = 0
else:
feature_usage_count = int(feature_usage) # type: ignore
# Increment the feature usage
r.set(f"{feature}_usage:{org_id}", feature_usage_count + 1)
return True
def decrease_feature_usage(
feature: FeatureSet,
org_id: int,
db_session: Session,
):
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
# Get the number of feature usage
feature_usage = r.get(f"{feature}_usage:{org_id}")
# Get a number of feature asks
if feature_usage is None:
feature_usage_count = 0
else:
feature_usage_count = int(feature_usage) # type: ignore
# Increment the feature usage
r.set(f"{feature}_usage:{org_id}", feature_usage_count - 1)
return True

View file

@ -2,7 +2,10 @@ from fastapi import Depends, HTTPException, Request
from sqlmodel import Session, select
from src.db.organization_config import OrganizationConfig
from src.db.organizations import Organization
from src.services.ai.utils import check_limits_and_config, count_ai_ask
from src.security.features_utils.usage import (
check_limits_with_usage,
increase_feature_usage,
)
from src.db.courses.courses import Course, CourseRead
from src.core.events.database import get_db_session
from src.db.users import PublicUser
@ -52,9 +55,15 @@ def ai_start_activity_chat_session(
statement = select(Organization).where(Organization.id == course.org_id)
org = db_session.exec(statement).first()
if not org or org.id is None:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
# Check limits and usage
check_limits_and_config(db_session, org) # type: ignore
count_ai_ask(org, "increment") # type: ignore
check_limits_with_usage("ai", org.id, db_session)
increase_feature_usage("ai", org.id, db_session)
if not activity:
raise HTTPException(
@ -147,8 +156,8 @@ def ai_send_activity_chat_message(
org = db_session.exec(statement).first()
# Check limits and usage
check_limits_and_config(db_session, org) # type: ignore
count_ai_ask(org, "increment") # type: ignore
check_limits_with_usage("ai", course.org_id, db_session)
increase_feature_usage("ai", course.org_id, db_session)
if not activity:
raise HTTPException(

View file

@ -1,114 +0,0 @@
from typing import Literal
import redis
from fastapi import HTTPException
from sqlmodel import Session, select
from config.config import get_learnhouse_config
from src.db.organization_config import OrganizationConfig
from src.db.organizations import Organization
def count_ai_ask(
organization: Organization,
operation: Literal["increment", "decrement"],
):
"""
Count the number of AI asks
"""
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Get the number of AI asks
ai_asks = r.get(f"ai_asks:{organization.org_uuid}")
if ai_asks is None:
ai_asks = 0
# Increment or decrement the number of AI asks
if operation == "increment":
ai_asks = int(ai_asks) + 1
elif operation == "decrement":
ai_asks = int(ai_asks) - 1
# Update the number of AI asks
r.set(f"ai_asks:{organization.org_uuid}", ai_asks)
# Set the expiration time to 30 days
r.expire(f"ai_asks:{organization.org_uuid}", 2592000)
def check_limits_and_config(db_session: Session, organization: Organization):
"""
Check the limits and config of an Organization
"""
# Get the Organization Config
statement = select(OrganizationConfig).where(
OrganizationConfig.org_id == organization.id
)
result = db_session.exec(statement)
org_config = result.first()
if org_config is None:
raise HTTPException(
status_code=404,
detail="Organization has no config",
)
# Check if the Organizations has AI enabled
if org_config.config["features"]["ai"]["enabled"] == False:
raise HTTPException(
status_code=403,
detail="Organization has AI disabled",
)
# Check if the Organization has Limits enabled and if the max_asks limit has been reached
if org_config.config["features"]["ai"]["limit"] > 0:
LH_CONFIG = get_learnhouse_config()
redis_conn_string = LH_CONFIG.redis_config.redis_connection_string
if not redis_conn_string:
raise HTTPException(
status_code=500,
detail="Redis connection string not found",
)
# Connect to Redis
r = redis.Redis.from_url(redis_conn_string)
if not r:
raise HTTPException(
status_code=500,
detail="Could not connect to Redis",
)
# Get the number of AI asks
ai_asks = r.get(f"ai_asks:{organization.org_uuid}")
# Get a number of AI asks
if ai_asks is None:
ai_asks = 0
else:
ai_asks = int(ai_asks)
# Check if the Number of asks is less than the max_asks limit
if org_config.config["features"]["ai"]["limit"] <= ai_asks:
raise HTTPException(
status_code=403,
detail="Organization has reached the max number of AI asks",
)

View file

@ -28,6 +28,11 @@ from src.db.organizations import Organization
from src.db.trail_runs import TrailRun
from src.db.trail_steps import TrailStep
from src.db.users import AnonymousUser, PublicUser, User
from src.security.features_utils.usage import (
check_limits_with_usage,
decrease_feature_usage,
increase_feature_usage,
)
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_element_is_public,
@ -61,6 +66,9 @@ async def create_assignment(
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "create", db_session)
# Usage check
check_limits_with_usage("assignments", course.org_id, db_session)
# Create Assignment
assignment = Assignment(**assignment_object.model_dump())
@ -74,6 +82,9 @@ async def create_assignment(
db_session.commit()
db_session.refresh(assignment)
# Feature usage
increase_feature_usage("assignments", course.org_id, db_session)
# return assignment read
return AssignmentRead.model_validate(assignment)
@ -228,6 +239,9 @@ async def delete_assignment(
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session)
# Feature usage
decrease_feature_usage("assignments", course.org_id, db_session)
# Delete Assignment
db_session.delete(assignment)
db_session.commit()
@ -275,6 +289,9 @@ async def delete_assignment_from_activity_uuid(
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session)
# Feature usage
decrease_feature_usage("assignments", course.org_id, db_session)
# Delete Assignment
db_session.delete(assignment)
@ -1119,9 +1136,9 @@ async def create_assignment_submission(
# Add TrailStep
trail = await check_trail_presence(
org_id=course.org_id,
user_id=user.id,
user_id=user.id, # type: ignore
request=request,
user=user,
user=user, # type: ignore
db_session=db_session,
)
@ -1137,7 +1154,7 @@ async def create_assignment_submission(
trail_id=trail.id if trail.id is not None else 0,
course_id=course.id if course.id is not None else 0,
org_id=course.org_id,
user_id=user.id,
user_id=user.id, # type: ignore
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
@ -1162,7 +1179,7 @@ async def create_assignment_submission(
complete=True,
teacher_verified=False,
grade="",
user_id=user.id,
user_id=user.id, # type: ignore
creation_date=str(datetime.now()),
update_date=str(datetime.now()),
)
@ -1410,7 +1427,6 @@ async def grade_assignment_submission(
detail="Course not found",
)
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Check if assignment user submission exists
@ -1552,7 +1568,6 @@ async def mark_activity_as_done_for_user(
detail="Course not found",
)
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
if not activity:
@ -1596,6 +1611,7 @@ async def mark_activity_as_done_for_user(
# return OK
return {"message": "Activity marked as done for user"}
async def get_assignments_from_course(
request: Request,
course_uuid: str,

View file

@ -5,6 +5,11 @@ from sqlmodel import Session, select
from src.db.usergroup_resources import UserGroupResource
from src.db.usergroup_user import UserGroupUser
from src.db.organizations import Organization
from src.security.features_utils.usage import (
check_limits_with_usage,
decrease_feature_usage,
increase_feature_usage,
)
from src.services.trail.trail import get_user_trail_with_orgid
from src.db.resource_authors import ResourceAuthor, ResourceAuthorshipEnum
from src.db.users import PublicUser, AnonymousUser, User, UserRead
@ -58,6 +63,7 @@ async def get_course(
return course
async def get_course_by_id(
request: Request,
course_id: str,
@ -91,6 +97,7 @@ async def get_course_by_id(
return course
async def get_course_meta(
request: Request,
course_uuid: str,
@ -158,6 +165,9 @@ async def create_course(
# RBAC check
await rbac_check(request, "course_x", current_user, "create", db_session)
# Usage check
check_limits_with_usage("courses", org_id, db_session)
# Complete course object
course.org_id = course.org_id
@ -207,6 +217,9 @@ async def create_course(
)
authors = db_session.exec(authors_statement).all()
# Feature usage
increase_feature_usage("courses", course.org_id, db_session)
# convert from User to UserRead
authors = [UserRead.model_validate(author) for author in authors]
@ -344,6 +357,9 @@ async def delete_course(
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "delete", db_session)
# Feature usage
decrease_feature_usage("courses", course.org_id, db_session)
db_session.delete(course)
db_session.commit()
@ -372,7 +388,7 @@ async def get_courses_orgslug(
statement_author = (
select(Course)
.join(Organization)
.join(ResourceAuthor, ResourceAuthor.user_id == current_user.id)
.join(ResourceAuthor, ResourceAuthor.user_id == current_user.id) # type: ignore
.where(
Organization.slug == org_slug,
ResourceAuthor.resource_uuid == Course.course_uuid,
@ -383,9 +399,9 @@ async def get_courses_orgslug(
statement_usergroup = (
select(Course)
.join(Organization)
.join(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid)
.join(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid) # type: ignore
.join(
UserGroupUser, UserGroupUser.usergroup_id == UserGroupResource.usergroup_id
UserGroupUser, UserGroupUser.usergroup_id == UserGroupResource.usergroup_id # type: ignore
)
.where(Organization.slug == org_slug, UserGroupUser.user_id == current_user.id)
)
@ -396,12 +412,11 @@ async def get_courses_orgslug(
).subquery()
# TODO: migrate this to exec
courses = db_session.execute(select(statement_complete)).all()
courses = db_session.execute(select(statement_complete)).all() # type: ignore
# TODO: I have no idea why this is necessary, but it is
courses = [CourseRead(**course._asdict(), authors=[]) for course in courses]
# for every course, get the authors
for course in courses:
authors_statement = (

View file

@ -6,6 +6,10 @@ from sqlmodel import Session, select
from src.db.organizations import Organization
from src.db.user_organizations import UserOrganization
from src.db.users import AnonymousUser, PublicUser, User
from src.security.features_utils.usage import (
check_limits_with_usage,
increase_feature_usage,
)
from src.services.orgs.invites import get_invite_code
from src.services.orgs.orgs import get_org_join_mechanism
@ -27,12 +31,14 @@ async def join_org(
org = result.first()
if not org:
if not org or org.id is None:
raise HTTPException(
status_code=404,
detail="Organization not found",
)
check_limits_with_usage("members", org.id, db_session)
join_method = await get_org_join_mechanism(
request, args.org_id, current_user, db_session
)
@ -104,6 +110,8 @@ async def join_org(
db_session.add(user_organization)
db_session.commit()
increase_feature_usage("members", org.id, db_session)
return "Great, You're part of the Organization"
else:

View file

@ -5,6 +5,7 @@ import logging
import redis
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from src.security.features_utils.usage import decrease_feature_usage
from src.services.orgs.invites import send_invite_email
from config.config import get_learnhouse_config
from src.services.orgs.orgs import rbac_check
@ -147,6 +148,8 @@ async def remove_user_from_org(
db_session.delete(user_org)
db_session.commit()
decrease_feature_usage("members", org_id, db_session)
return {"detail": "User removed from org"}

View file

@ -4,6 +4,10 @@ from typing import Literal
from uuid import uuid4
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from src.security.features_utils.usage import (
check_limits_with_usage,
increase_feature_usage,
)
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_if_user_is_anon,
@ -35,14 +39,17 @@ async def create_usergroup(
# Check if Organization exists
statement = select(Organization).where(Organization.id == usergroup_create.org_id)
result = db_session.exec(statement)
org = db_session.exec(statement).first()
if not result.first():
if not org or org.id is None:
raise HTTPException(
status_code=400,
detail="Organization does not exist",
)
# Usage check
check_limits_with_usage("courses", org.id, db_session)
# Complete the object
usergroup.usergroup_uuid = f"usergroup_{uuid4()}"
usergroup.creation_date = str(datetime.now())
@ -53,6 +60,9 @@ async def create_usergroup(
db_session.commit()
db_session.refresh(usergroup)
# Feature usage
increase_feature_usage("usergroups", org.id, db_session)
usergroup = UserGroupRead.model_validate(usergroup)
return usergroup
@ -253,6 +263,9 @@ async def delete_usergroup_by_id(
db_session=db_session,
)
# Feature usage
increase_feature_usage("usergroups", usergroup.org_id, db_session)
db_session.delete(usergroup)
db_session.commit()
@ -276,8 +289,6 @@ async def add_users_to_usergroup(
detail="UserGroup not found",
)
# RBAC check
await rbac_check(
request,
@ -353,7 +364,9 @@ async def remove_users_from_usergroup(
user_ids_array = user_ids.split(",")
for user_id in user_ids_array:
statement = select(UserGroupUser).where(UserGroupUser.user_id == user_id, UserGroupUser.usergroup_id == usergroup_id)
statement = select(UserGroupUser).where(
UserGroupUser.user_id == user_id, UserGroupUser.usergroup_id == usergroup_id
)
usergroup_user = db_session.exec(statement).first()
if usergroup_user:

View file

@ -3,6 +3,10 @@ from typing import Literal
from uuid import uuid4
from fastapi import HTTPException, Request, UploadFile, status
from sqlmodel import Session, select
from src.security.features_utils.usage import (
check_limits_with_usage,
increase_feature_usage,
)
from src.services.users.usergroups import add_users_to_usergroup
from src.services.users.emails import (
send_account_creation_email,
@ -61,6 +65,9 @@ async def create_user(
detail="Organization does not exist",
)
# Usage check
check_limits_with_usage("members", org_id, db_session)
# Username
statement = select(User).where(User.username == user.username)
result = db_session.exec(statement)
@ -106,6 +113,8 @@ async def create_user(
user = UserRead.model_validate(user)
increase_feature_usage("members", org_id, db_session)
# Send Account creation email
send_account_creation_email(
user=user,
@ -135,6 +144,9 @@ async def create_user_with_invite(
detail="Invite code is incorrect",
)
# Usage check
check_limits_with_usage("members", org_id, db_session)
# Check if invite code contains UserGroup
if inviteCode.usergroup_id:
# Add user to UserGroup
@ -148,6 +160,8 @@ async def create_user_with_invite(
user = await create_user(request, db_session, current_user, user_object, org_id)
increase_feature_usage("members", org_id, db_session)
return user

View file

@ -15,7 +15,8 @@ import { getAPIUrl } from '@services/config/config'
import { mutate } from 'swr'
import { createAssignment } from '@services/courses/assignments'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { createActivity } from '@services/courses/activities'
import { createActivity, deleteActivity } from '@services/courses/activities'
import toast from 'react-hot-toast'
function NewAssignment({ submitActivity, chapterId, course, closeModal }: any) {
const org = useOrg() as any;
@ -55,7 +56,7 @@ function NewAssignment({ submitActivity, chapterId, course, closeModal }: any) {
}
const activity_res = await createActivity(activity, chapterId, org?.id, session.data?.tokens?.access_token)
await createAssignment({
const res = await createAssignment({
title: activityName,
description: activityDescription,
due_date: dueDate,
@ -66,6 +67,14 @@ function NewAssignment({ submitActivity, chapterId, course, closeModal }: any) {
activity_id: activity_res?.id,
}, session.data?.tokens?.access_token)
if (res.success) {
toast.success('Assignment created successfully')
} else {
toast.error(res.data.detail)
await deleteActivity(activity_res.activity_uuid, session.data?.tokens?.access_token)
}
mutate(`${getAPIUrl()}courses/${course.courseStructure.course_uuid}/meta`)
setIsSubmitting(false)
closeModal()

View file

@ -16,6 +16,7 @@ import { BarLoader } from 'react-spinners'
import { revalidateTags } from '@services/utils/ts/requests'
import { useRouter } from 'next/navigation'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import toast from 'react-hot-toast'
function CreateCourseModal({ closeModal, orgslug }: any) {
const [isSubmitting, setIsSubmitting] = useState(false)
@ -69,21 +70,27 @@ function CreateCourseModal({ closeModal, orgslug }: any) {
e.preventDefault()
setIsSubmitting(true)
let status = await createNewCourse(
let res = await createNewCourse(
orgId,
{ name, description, tags, visibility },
thumbnail,
session.data?.tokens?.access_token
)
if (res.success) {
await revalidateTags(['courses'], orgslug)
setIsSubmitting(false)
toast.success('Course created successfully')
if (status.org_id == orgId) {
if (res.data.org_id == orgId) {
closeModal()
router.refresh()
await revalidateTags(['courses'], orgslug)
} else {
alert('Error creating course, please see console logs')
}
}
else {
setIsSubmitting(false)
toast.error(res.data.detail)
}
}

View file

@ -99,9 +99,9 @@ export async function getActivityByID(
return res
}
export async function deleteActivity(activity_id: any, access_token: string) {
export async function deleteActivity(activity_uuid: any, access_token: string) {
const result = await fetch(
`${getAPIUrl()}activities/${activity_id}`,
`${getAPIUrl()}activities/${activity_uuid}`,
RequestBodyWithAuthHeader('DELETE', null, null, access_token)
)
const res = await result.json()

View file

@ -98,7 +98,7 @@ export async function createNewCourse(
`${getAPIUrl()}courses/?org_id=${org_id}`,
RequestBodyFormWithAuthHeader('POST', formData, null, access_token)
)
const res = await errorHandling(result)
const res = await getResponseMetadata(result)
return res
}