Merge pull request #316 from learnhouse/feat/payments

Payments & Subscriptions
This commit is contained in:
Badr B. 2024-11-26 00:03:48 +01:00 committed by GitHub
commit 60740c4166
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
176 changed files with 7282 additions and 1882 deletions

View file

@ -1,6 +1,8 @@
name: App Build
on:
push:
branches:
- dev
paths:
- "**"
pull_request:
@ -14,3 +16,14 @@ jobs:
- name: Build Docker Image
run: docker build -t learnhouse .
working-directory: .
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@v2
with:
token: ${{ secrets.PAT_TOKEN }}
repository: learnhouse/images
event-type: build-images
client-payload: |
{
"ref": "${{ github.sha }}",
"branch": "${{ github.head_ref || github.ref_name }}"
}

View file

@ -48,6 +48,7 @@ def install(
slug="default",
email="",
logo_image="",
thumbnail_image="",
)
install_create_organization(org, db_session)
print("Default organization created ✅")
@ -89,6 +90,7 @@ def install(
slug=slug.lower(),
email="",
logo_image="",
thumbnail_image="",
)
install_create_organization(org, db_session)
print(orgname + " Organization created ✅")

View file

@ -71,6 +71,18 @@ class RedisConfig(BaseModel):
redis_connection_string: Optional[str]
class InternalStripeConfig(BaseModel):
stripe_secret_key: str | None
stripe_publishable_key: str | None
stripe_webhook_standard_secret: str | None
stripe_webhook_connect_secret: str | None
stripe_client_id: str | None
class InternalPaymentsConfig(BaseModel):
stripe: InternalStripeConfig
class LearnHouseConfig(BaseModel):
site_name: str
site_description: str
@ -82,6 +94,7 @@ class LearnHouseConfig(BaseModel):
security_config: SecurityConfig
ai_config: AIConfig
mailing_config: MailingConfig
payments_config: InternalPaymentsConfig
def get_learnhouse_config() -> LearnHouseConfig:
@ -261,6 +274,33 @@ def get_learnhouse_config() -> LearnHouseConfig:
else:
sentry_config = None
# Payments config
env_stripe_secret_key = os.environ.get("LEARNHOUSE_STRIPE_SECRET_KEY")
env_stripe_publishable_key = os.environ.get("LEARNHOUSE_STRIPE_PUBLISHABLE_KEY")
env_stripe_webhook_standard_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_STANDARD_SECRET")
env_stripe_webhook_connect_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_CONNECT_SECRET")
env_stripe_client_id = os.environ.get("LEARNHOUSE_STRIPE_CLIENT_ID")
stripe_secret_key = env_stripe_secret_key or yaml_config.get("payments_config", {}).get(
"stripe", {}
).get("stripe_secret_key")
stripe_publishable_key = env_stripe_publishable_key or yaml_config.get("payments_config", {}).get(
"stripe", {}
).get("stripe_publishable_key")
stripe_webhook_standard_secret = env_stripe_webhook_standard_secret or yaml_config.get("payments_config", {}).get(
"stripe", {}
).get("stripe_webhook_standard_secret")
stripe_webhook_connect_secret = env_stripe_webhook_connect_secret or yaml_config.get("payments_config", {}).get(
"stripe", {}
).get("stripe_webhook_connect_secret")
stripe_client_id = env_stripe_client_id or yaml_config.get("payments_config", {}).get(
"stripe", {}
).get("stripe_client_id")
# Create HostingConfig and DatabaseConfig objects
hosting_config = HostingConfig(
domain=domain,
@ -303,6 +343,15 @@ def get_learnhouse_config() -> LearnHouseConfig:
mailing_config=MailingConfig(
resend_api_key=resend_api_key, system_email_address=system_email_address
),
payments_config=InternalPaymentsConfig(
stripe=InternalStripeConfig(
stripe_secret_key=stripe_secret_key,
stripe_publishable_key=stripe_publishable_key,
stripe_webhook_standard_secret=stripe_webhook_standard_secret,
stripe_webhook_connect_secret=stripe_webhook_connect_secret,
stripe_client_id=stripe_client_id
)
)
)
return config

View file

@ -37,6 +37,13 @@ database_config:
redis_config:
redis_connection_string: redis://localhost:6379/learnhouse
payments_config:
stripe:
stripe_secret_key: ""
stripe_publishable_key: ""
stripe_webhook_standard_secret: ""
stripe_client_id: ""
ai_config:
chromadb_config:
isSeparateDatabaseEnabled: True

View file

@ -0,0 +1,90 @@
"""Payments
Revision ID: 0314ec7791e1
Revises: 040ccb1d456e
Create Date: 2024-11-23 19:41:14.064680
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa # noqa: F401
import sqlmodel # noqa: F401
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '0314ec7791e1'
down_revision: Union[str, None] = '040ccb1d456e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('paymentsconfig',
sa.Column('enabled', sa.Boolean(), nullable=False),
sa.Column('active', sa.Boolean(), nullable=False),
sa.Column('provider', postgresql.ENUM('STRIPE', name='paymentproviderenum', create_type=False), nullable=False),
sa.Column('provider_specific_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('provider_config', sa.JSON(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('org_id', sa.BigInteger(), nullable=True),
sa.Column('creation_date', sa.DateTime(), nullable=False),
sa.Column('update_date', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('paymentsproduct',
sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True),
sa.Column('product_type', postgresql.ENUM('SUBSCRIPTION', 'ONE_TIME', name='paymentproducttypeenum', create_type=False), nullable=False),
sa.Column('price_type', postgresql.ENUM('CUSTOMER_CHOICE', 'FIXED_PRICE', name='paymentpricetypeenum', create_type=False), nullable=False),
sa.Column('benefits', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('currency', sqlmodel.sql.sqltypes.AutoString(), nullable=False),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('org_id', sa.BigInteger(), nullable=True),
sa.Column('payments_config_id', sa.BigInteger(), nullable=True),
sa.Column('provider_product_id', sa.String(), nullable=True),
sa.Column('creation_date', sa.DateTime(), nullable=False),
sa.Column('update_date', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['payments_config_id'], ['paymentsconfig.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('paymentscourse',
sa.Column('course_id', sa.BigInteger(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('payment_product_id', sa.BigInteger(), nullable=True),
sa.Column('org_id', sa.BigInteger(), nullable=True),
sa.Column('creation_date', sa.DateTime(), nullable=False),
sa.Column('update_date', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['course_id'], ['course.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['payment_product_id'], ['paymentsproduct.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_table('paymentsuser',
sa.Column('status', postgresql.ENUM('PENDING', 'COMPLETED', 'ACTIVE', 'CANCELLED', 'FAILED', 'REFUNDED', name='paymentstatusenum', create_type=False), nullable=False),
sa.Column('provider_specific_data', sa.JSON(), nullable=True),
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.BigInteger(), nullable=True),
sa.Column('org_id', sa.BigInteger(), nullable=True),
sa.Column('payment_product_id', sa.BigInteger(), nullable=True),
sa.Column('creation_date', sa.DateTime(), nullable=False),
sa.Column('update_date', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['org_id'], ['organization.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['payment_product_id'], ['paymentsproduct.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('paymentsuser')
op.drop_table('paymentscourse')
op.drop_table('paymentsproduct')
op.drop_table('paymentsconfig')
# ### end Alembic commands ###

361
apps/api/poetry.lock generated
View file

@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
[[package]]
name = "aiohappyeyeballs"
@ -276,17 +276,17 @@ typecheck = ["mypy"]
[[package]]
name = "boto3"
version = "1.35.49"
version = "1.35.52"
description = "The AWS SDK for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "boto3-1.35.49-py3-none-any.whl", hash = "sha256:b660c649a27a6b47a34f6f858f5bd7c3b0a798a16dec8dda7cbebeee80fd1f60"},
{file = "boto3-1.35.49.tar.gz", hash = "sha256:ddecb27f5699ca9f97711c52b6c0652c2e63bf6c2bfbc13b819b4f523b4d30ff"},
{file = "boto3-1.35.52-py3-none-any.whl", hash = "sha256:ec0e797441db56af63b1150bba49f114b0f885f5d76c3b6dc18075f73030d2bb"},
{file = "boto3-1.35.52.tar.gz", hash = "sha256:68299da8ab2bb37cc843d61b9f4c1c9367438406cfd65a8f593afc7b3bfe226d"},
]
[package.dependencies]
botocore = ">=1.35.49,<1.36.0"
botocore = ">=1.35.52,<1.36.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.10.0,<0.11.0"
@ -295,13 +295,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
version = "1.35.49"
version = "1.35.52"
description = "Low-level, data-driven core of boto 3."
optional = false
python-versions = ">=3.8"
files = [
{file = "botocore-1.35.49-py3-none-any.whl", hash = "sha256:aed4d3643afd702920792b68fbe712a8c3847993820d1048cd238a6469354da1"},
{file = "botocore-1.35.49.tar.gz", hash = "sha256:07d0c1325fdbfa49a4a054413dbdeab0a6030449b2aa66099241af2dac48afd8"},
{file = "botocore-1.35.52-py3-none-any.whl", hash = "sha256:cdbb5e43c9c3a977763e2a10d3b8b9c405d51279f9fcfd4ca4800763b22acba5"},
{file = "botocore-1.35.52.tar.gz", hash = "sha256:1fe7485ea13d638b089103addd818c12984ff1e4d208de15f180b1e25ad944c5"},
]
[package.dependencies]
@ -514,13 +514,13 @@ numpy = "*"
[[package]]
name = "chromadb"
version = "0.5.11"
version = "0.5.16"
description = "Chroma."
optional = false
python-versions = ">=3.8"
files = [
{file = "chromadb-0.5.11-py3-none-any.whl", hash = "sha256:f02d9326869cea926f980bd6c9a0150a0ef2e151072f325998c16a9502fb4b25"},
{file = "chromadb-0.5.11.tar.gz", hash = "sha256:252e970b3e1a27b594cc7b3685238691bf8eaa232225d4dee9e33ec83580775f"},
{file = "chromadb-0.5.16-py3-none-any.whl", hash = "sha256:ae96f1c81fa691a163a2d625dc769c5c1afa3219d1ac26796fbf9d60d7924d71"},
{file = "chromadb-0.5.16.tar.gz", hash = "sha256:ab947065125908b228cc343e7d9f21bcea5036dcd237d993caa66e5fc262dd9e"},
]
[package.dependencies]
@ -1053,70 +1053,70 @@ test = ["objgraph", "psutil"]
[[package]]
name = "grpcio"
version = "1.67.0"
version = "1.67.1"
description = "HTTP/2-based RPC framework"
optional = false
python-versions = ">=3.8"
files = [
{file = "grpcio-1.67.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:bd79929b3bb96b54df1296cd3bf4d2b770bd1df6c2bdf549b49bab286b925cdc"},
{file = "grpcio-1.67.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:16724ffc956ea42967f5758c2f043faef43cb7e48a51948ab593570570d1e68b"},
{file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:2b7183c80b602b0ad816315d66f2fb7887614ead950416d60913a9a71c12560d"},
{file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:efe32b45dd6d118f5ea2e5deaed417d8a14976325c93812dd831908522b402c9"},
{file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe89295219b9c9e47780a0f1c75ca44211e706d1c598242249fe717af3385ec8"},
{file = "grpcio-1.67.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa8d025fae1595a207b4e47c2e087cb88d47008494db258ac561c00877d4c8f8"},
{file = "grpcio-1.67.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f95e15db43e75a534420e04822df91f645664bf4ad21dfaad7d51773c80e6bb4"},
{file = "grpcio-1.67.0-cp310-cp310-win32.whl", hash = "sha256:a6b9a5c18863fd4b6624a42e2712103fb0f57799a3b29651c0e5b8119a519d65"},
{file = "grpcio-1.67.0-cp310-cp310-win_amd64.whl", hash = "sha256:b6eb68493a05d38b426604e1dc93bfc0137c4157f7ab4fac5771fd9a104bbaa6"},
{file = "grpcio-1.67.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:e91d154689639932305b6ea6f45c6e46bb51ecc8ea77c10ef25aa77f75443ad4"},
{file = "grpcio-1.67.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cb204a742997277da678611a809a8409657b1398aaeebf73b3d9563b7d154c13"},
{file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:ae6de510f670137e755eb2a74b04d1041e7210af2444103c8c95f193340d17ee"},
{file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74b900566bdf68241118f2918d312d3bf554b2ce0b12b90178091ea7d0a17b3d"},
{file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4e95e43447a02aa603abcc6b5e727d093d161a869c83b073f50b9390ecf0fa8"},
{file = "grpcio-1.67.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0bb94e66cd8f0baf29bd3184b6aa09aeb1a660f9ec3d85da615c5003154bc2bf"},
{file = "grpcio-1.67.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:82e5bd4b67b17c8c597273663794a6a46a45e44165b960517fe6d8a2f7f16d23"},
{file = "grpcio-1.67.0-cp311-cp311-win32.whl", hash = "sha256:7fc1d2b9fd549264ae585026b266ac2db53735510a207381be509c315b4af4e8"},
{file = "grpcio-1.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac11ecb34a86b831239cc38245403a8de25037b448464f95c3315819e7519772"},
{file = "grpcio-1.67.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:227316b5631260e0bef8a3ce04fa7db4cc81756fea1258b007950b6efc90c05d"},
{file = "grpcio-1.67.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d90cfdafcf4b45a7a076e3e2a58e7bc3d59c698c4f6470b0bb13a4d869cf2273"},
{file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:77196216d5dd6f99af1c51e235af2dd339159f657280e65ce7e12c1a8feffd1d"},
{file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15c05a26a0f7047f720da41dc49406b395c1470eef44ff7e2c506a47ac2c0591"},
{file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3840994689cc8cbb73d60485c594424ad8adb56c71a30d8948d6453083624b52"},
{file = "grpcio-1.67.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5a1e03c3102b6451028d5dc9f8591131d6ab3c8a0e023d94c28cb930ed4b5f81"},
{file = "grpcio-1.67.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:682968427a63d898759474e3b3178d42546e878fdce034fd7474ef75143b64e3"},
{file = "grpcio-1.67.0-cp312-cp312-win32.whl", hash = "sha256:d01793653248f49cf47e5695e0a79805b1d9d4eacef85b310118ba1dfcd1b955"},
{file = "grpcio-1.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:985b2686f786f3e20326c4367eebdaed3e7aa65848260ff0c6644f817042cb15"},
{file = "grpcio-1.67.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:8c9a35b8bc50db35ab8e3e02a4f2a35cfba46c8705c3911c34ce343bd777813a"},
{file = "grpcio-1.67.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:42199e704095b62688998c2d84c89e59a26a7d5d32eed86d43dc90e7a3bd04aa"},
{file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:c4c425f440fb81f8d0237c07b9322fc0fb6ee2b29fbef5f62a322ff8fcce240d"},
{file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:323741b6699cd2b04a71cb38f502db98f90532e8a40cb675393d248126a268af"},
{file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:662c8e105c5e5cee0317d500eb186ed7a93229586e431c1bf0c9236c2407352c"},
{file = "grpcio-1.67.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f6bd2ab135c64a4d1e9e44679a616c9bc944547357c830fafea5c3caa3de5153"},
{file = "grpcio-1.67.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2f55c1e0e2ae9bdd23b3c63459ee4c06d223b68aeb1961d83c48fb63dc29bc03"},
{file = "grpcio-1.67.0-cp313-cp313-win32.whl", hash = "sha256:fd6bc27861e460fe28e94226e3673d46e294ca4673d46b224428d197c5935e69"},
{file = "grpcio-1.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:cf51d28063338608cd8d3cd64677e922134837902b70ce00dad7f116e3998210"},
{file = "grpcio-1.67.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:7f200aca719c1c5dc72ab68be3479b9dafccdf03df530d137632c534bb6f1ee3"},
{file = "grpcio-1.67.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0892dd200ece4822d72dd0952f7112c542a487fc48fe77568deaaa399c1e717d"},
{file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:f4d613fbf868b2e2444f490d18af472ccb47660ea3df52f068c9c8801e1f3e85"},
{file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c69bf11894cad9da00047f46584d5758d6ebc9b5950c0dc96fec7e0bce5cde9"},
{file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9bca3ca0c5e74dea44bf57d27e15a3a3996ce7e5780d61b7c72386356d231db"},
{file = "grpcio-1.67.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:014dfc020e28a0d9be7e93a91f85ff9f4a87158b7df9952fe23cc42d29d31e1e"},
{file = "grpcio-1.67.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d4ea4509d42c6797539e9ec7496c15473177ce9abc89bc5c71e7abe50fc25737"},
{file = "grpcio-1.67.0-cp38-cp38-win32.whl", hash = "sha256:9d75641a2fca9ae1ae86454fd25d4c298ea8cc195dbc962852234d54a07060ad"},
{file = "grpcio-1.67.0-cp38-cp38-win_amd64.whl", hash = "sha256:cff8e54d6a463883cda2fab94d2062aad2f5edd7f06ae3ed030f2a74756db365"},
{file = "grpcio-1.67.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:62492bd534979e6d7127b8a6b29093161a742dee3875873e01964049d5250a74"},
{file = "grpcio-1.67.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eef1dce9d1a46119fd09f9a992cf6ab9d9178b696382439446ca5f399d7b96fe"},
{file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:f623c57a5321461c84498a99dddf9d13dac0e40ee056d884d6ec4ebcab647a78"},
{file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54d16383044e681f8beb50f905249e4e7261dd169d4aaf6e52eab67b01cbbbe2"},
{file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2a44e572fb762c668e4812156b81835f7aba8a721b027e2d4bb29fb50ff4d33"},
{file = "grpcio-1.67.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:391df8b0faac84d42f5b8dfc65f5152c48ed914e13c522fd05f2aca211f8bfad"},
{file = "grpcio-1.67.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfd9306511fdfc623a1ba1dc3bc07fbd24e6cfbe3c28b4d1e05177baa2f99617"},
{file = "grpcio-1.67.0-cp39-cp39-win32.whl", hash = "sha256:30d47dbacfd20cbd0c8be9bfa52fdb833b395d4ec32fe5cff7220afc05d08571"},
{file = "grpcio-1.67.0-cp39-cp39-win_amd64.whl", hash = "sha256:f55f077685f61f0fbd06ea355142b71e47e4a26d2d678b3ba27248abfe67163a"},
{file = "grpcio-1.67.0.tar.gz", hash = "sha256:e090b2553e0da1c875449c8e75073dd4415dd71c9bde6a406240fdf4c0ee467c"},
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
]
[package.extras]
protobuf = ["grpcio-tools (>=1.67.0)"]
protobuf = ["grpcio-tools (>=1.67.1)"]
[[package]]
name = "h11"
@ -1232,13 +1232,13 @@ zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "huggingface-hub"
version = "0.26.1"
version = "0.26.2"
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
optional = false
python-versions = ">=3.8.0"
files = [
{file = "huggingface_hub-0.26.1-py3-none-any.whl", hash = "sha256:5927a8fc64ae68859cd954b7cc29d1c8390a5e15caba6d3d349c973be8fdacf3"},
{file = "huggingface_hub-0.26.1.tar.gz", hash = "sha256:414c0d9b769eecc86c70f9d939d0f48bb28e8461dd1130021542eff0212db890"},
{file = "huggingface_hub-0.26.2-py3-none-any.whl", hash = "sha256:98c2a5a8e786c7b2cb6fdeb2740893cba4d53e312572ed3d8afafda65b128c46"},
{file = "huggingface_hub-0.26.2.tar.gz", hash = "sha256:b100d853465d965733964d123939ba287da60a547087783ddff8a323f340332b"},
]
[package.dependencies]
@ -1599,13 +1599,13 @@ tenacity = ">=8.1.0,<9.0.0"
[[package]]
name = "langchain-core"
version = "0.2.41"
version = "0.2.42"
description = "Building applications with LLMs through composability"
optional = false
python-versions = "<4.0,>=3.8.1"
files = [
{file = "langchain_core-0.2.41-py3-none-any.whl", hash = "sha256:3278fda5ba9a05defae8bb19f1226032add6aab21917db7b3bc74e750e263e84"},
{file = "langchain_core-0.2.41.tar.gz", hash = "sha256:bc12032c5a298d85be754ccb129bc13ea21ccb1d6e22f8d7ba18b8da64315bb5"},
{file = "langchain_core-0.2.42-py3-none-any.whl", hash = "sha256:09503fdfb9efa163e51f2d9762894fde04797d0a41462c0e6072ef78028e48fd"},
{file = "langchain_core-0.2.42.tar.gz", hash = "sha256:e4ea04b22bd6398048d0ef97cd3132fbdd80e6c749863ee96e6b7c88502ff913"},
]
[package.dependencies]
@ -1698,13 +1698,13 @@ requests = ">=2,<3"
[[package]]
name = "langsmith"
version = "0.1.137"
version = "0.1.138"
description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform."
optional = false
python-versions = "<4.0,>=3.8.1"
files = [
{file = "langsmith-0.1.137-py3-none-any.whl", hash = "sha256:4256d5c61133749890f7b5c88321dbb133ce0f440c621ea28e76513285859b81"},
{file = "langsmith-0.1.137.tar.gz", hash = "sha256:56cdfcc6c74cb20a3f437d5bd144feb5bf93f54c5a2918d1e568cbd084a372d4"},
{file = "langsmith-0.1.138-py3-none-any.whl", hash = "sha256:5c2bd5c11c75f7b3d06a0f06b115186e7326ca969fd26d66ffc65a0669012aee"},
{file = "langsmith-0.1.138.tar.gz", hash = "sha256:1ecf613bb52f6bf17f1510e24ad8b70d4b0259bc9d3dbfd69b648c66d4644f0b"},
]
[package.dependencies]
@ -2215,13 +2215,13 @@ sympy = "*"
[[package]]
name = "openai"
version = "1.52.2"
version = "1.53.0"
description = "The official Python library for the openai API"
optional = false
python-versions = ">=3.7.1"
files = [
{file = "openai-1.52.2-py3-none-any.whl", hash = "sha256:57e9e37bc407f39bb6ec3a27d7e8fb9728b2779936daa1fcf95df17d3edfaccc"},
{file = "openai-1.52.2.tar.gz", hash = "sha256:87b7d0f69d85f5641678d414b7ee3082363647a5c66a462ed7f3ccb59582da0d"},
{file = "openai-1.53.0-py3-none-any.whl", hash = "sha256:20f408c32fc5cb66e60c6882c994cdca580a5648e10045cd840734194f033418"},
{file = "openai-1.53.0.tar.gz", hash = "sha256:be2c4e77721b166cce8130e544178b7d579f751b4b074ffbaade3854b6f85ec5"},
]
[package.dependencies]
@ -3332,23 +3332,23 @@ tornado = ["tornado (>=6)"]
[[package]]
name = "setuptools"
version = "75.2.0"
version = "75.3.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"},
{file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"},
{file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"},
{file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"},
]
[package.extras]
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"]
core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
cover = ["pytest-cov"]
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
enabler = ["pytest-enabler (>=2.2)"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"]
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"]
[[package]]
name = "shellingham"
@ -3538,6 +3538,21 @@ anyio = ">=3.4.0,<5"
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
[[package]]
name = "stripe"
version = "11.2.0"
description = "Python bindings for the Stripe API"
optional = false
python-versions = ">=3.6"
files = [
{file = "stripe-11.2.0-py2.py3-none-any.whl", hash = "sha256:dec812eabc95488862be40e6c799acdaf2e1225d686490a793f949fab745fdd0"},
{file = "stripe-11.2.0.tar.gz", hash = "sha256:4c53d61d7b596070324bfa5d7215843145fe5466e48973d828aab41ad209b5ce"},
]
[package.dependencies]
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
[[package]]
name = "sympy"
version = "1.13.3"
@ -3741,13 +3756,13 @@ testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"]
[[package]]
name = "tqdm"
version = "4.66.5"
version = "4.66.6"
description = "Fast, Extensible Progress Meter"
optional = false
python-versions = ">=3.7"
files = [
{file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"},
{file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"},
{file = "tqdm-4.66.6-py3-none-any.whl", hash = "sha256:223e8b5359c2efc4b30555531f09e9f2f3589bcd7fdd389271191031b49b7a63"},
{file = "tqdm-4.66.6.tar.gz", hash = "sha256:4bdd694238bef1485ce839d67967ab50af8f9272aab687c0d7702a01da0be090"},
]
[package.dependencies]
@ -4182,93 +4197,93 @@ files = [
[[package]]
name = "yarl"
version = "1.16.0"
version = "1.17.1"
description = "Yet another URL library"
optional = false
python-versions = ">=3.9"
files = [
{file = "yarl-1.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32468f41242d72b87ab793a86d92f885355bcf35b3355aa650bfa846a5c60058"},
{file = "yarl-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:234f3a3032b505b90e65b5bc6652c2329ea7ea8855d8de61e1642b74b4ee65d2"},
{file = "yarl-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a0296040e5cddf074c7f5af4a60f3fc42c0237440df7bcf5183be5f6c802ed5"},
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de6c14dd7c7c0badba48157474ea1f03ebee991530ba742d381b28d4f314d6f3"},
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b140e532fe0266003c936d017c1ac301e72ee4a3fd51784574c05f53718a55d8"},
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:019f5d58093402aa8f6661e60fd82a28746ad6d156f6c5336a70a39bd7b162b9"},
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c42998fd1cbeb53cd985bff0e4bc25fbe55fd6eb3a545a724c1012d69d5ec84"},
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c7c30fb38c300fe8140df30a046a01769105e4cf4282567a29b5cdb635b66c4"},
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e49e0fd86c295e743fd5be69b8b0712f70a686bc79a16e5268386c2defacaade"},
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b9ca7b9147eb1365c8bab03c003baa1300599575effad765e0b07dd3501ea9af"},
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27e11db3f1e6a51081a981509f75617b09810529de508a181319193d320bc5c7"},
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8994c42f4ca25df5380ddf59f315c518c81df6a68fed5bb0c159c6cb6b92f120"},
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:542fa8e09a581bcdcbb30607c7224beff3fdfb598c798ccd28a8184ffc18b7eb"},
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2bd6a51010c7284d191b79d3b56e51a87d8e1c03b0902362945f15c3d50ed46b"},
{file = "yarl-1.16.0-cp310-cp310-win32.whl", hash = "sha256:178ccb856e265174a79f59721031060f885aca428983e75c06f78aa24b91d929"},
{file = "yarl-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe8bba2545427418efc1929c5c42852bdb4143eb8d0a46b09de88d1fe99258e7"},
{file = "yarl-1.16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d8643975a0080f361639787415a038bfc32d29208a4bf6b783ab3075a20b1ef3"},
{file = "yarl-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:676d96bafc8c2d0039cea0cd3fd44cee7aa88b8185551a2bb93354668e8315c2"},
{file = "yarl-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9525f03269e64310416dbe6c68d3b23e5d34aaa8f47193a1c45ac568cecbc49"},
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37d5ec034e668b22cf0ce1074d6c21fd2a08b90d11b1b73139b750a8b0dd97"},
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f32c4cb7386b41936894685f6e093c8dfaf0960124d91fe0ec29fe439e201d0"},
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b8e265a0545637492a7e12fd7038370d66c9375a61d88c5567d0e044ded9202"},
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:789a3423f28a5fff46fbd04e339863c169ece97c827b44de16e1a7a42bc915d2"},
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1d1f45e3e8d37c804dca99ab3cf4ab3ed2e7a62cd82542924b14c0a4f46d243"},
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:621280719c4c5dad4c1391160a9b88925bb8b0ff6a7d5af3224643024871675f"},
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed097b26f18a1f5ff05f661dc36528c5f6735ba4ce8c9645e83b064665131349"},
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2f1fe2b2e3ee418862f5ebc0c0083c97f6f6625781382f828f6d4e9b614eba9b"},
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:87dd10bc0618991c66cee0cc65fa74a45f4ecb13bceec3c62d78ad2e42b27a16"},
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4199db024b58a8abb2cfcedac7b1292c3ad421684571aeb622a02f242280e8d6"},
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:99a9dcd4b71dd5f5f949737ab3f356cfc058c709b4f49833aeffedc2652dac56"},
{file = "yarl-1.16.0-cp311-cp311-win32.whl", hash = "sha256:a9394c65ae0ed95679717d391c862dece9afacd8fa311683fc8b4362ce8a410c"},
{file = "yarl-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5b9101f528ae0f8f65ac9d64dda2bb0627de8a50344b2f582779f32fda747c1d"},
{file = "yarl-1.16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4ffb7c129707dd76ced0a4a4128ff452cecf0b0e929f2668ea05a371d9e5c104"},
{file = "yarl-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1a5e9d8ce1185723419c487758d81ac2bde693711947032cce600ca7c9cda7d6"},
{file = "yarl-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d743e3118b2640cef7768ea955378c3536482d95550222f908f392167fe62059"},
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26768342f256e6e3c37533bf9433f5f15f3e59e3c14b2409098291b3efaceacb"},
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1b0796168b953bca6600c5f97f5ed407479889a36ad7d17183366260f29a6b9"},
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:858728086914f3a407aa7979cab743bbda1fe2bdf39ffcd991469a370dd7414d"},
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5570e6d47bcb03215baf4c9ad7bf7c013e56285d9d35013541f9ac2b372593e7"},
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66ea8311422a7ba1fc79b4c42c2baa10566469fe5a78500d4e7754d6e6db8724"},
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:649bddcedee692ee8a9b7b6e38582cb4062dc4253de9711568e5620d8707c2a3"},
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a91654adb7643cb21b46f04244c5a315a440dcad63213033826549fa2435f71"},
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b439cae82034ade094526a8f692b9a2b5ee936452de5e4c5f0f6c48df23f8604"},
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:571f781ae8ac463ce30bacebfaef2c6581543776d5970b2372fbe31d7bf31a07"},
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:aa7943f04f36d6cafc0cf53ea89824ac2c37acbdb4b316a654176ab8ffd0f968"},
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a5cf32539373ff39d97723e39a9283a7277cbf1224f7aef0c56c9598b6486c3"},
{file = "yarl-1.16.0-cp312-cp312-win32.whl", hash = "sha256:a5b6c09b9b4253d6a208b0f4a2f9206e511ec68dce9198e0fbec4f160137aa67"},
{file = "yarl-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1208ca14eed2fda324042adf8d6c0adf4a31522fa95e0929027cd487875f0240"},
{file = "yarl-1.16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ace0177520bd4caa99295a9b6fb831d0e9a57d8e0501a22ffaa61b4c024283"},
{file = "yarl-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7118bdb5e3ed81acaa2095cba7ec02a0fe74b52a16ab9f9ac8e28e53ee299732"},
{file = "yarl-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38fec8a2a94c58bd47c9a50a45d321ab2285ad133adefbbadf3012c054b7e656"},
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8791d66d81ee45866a7bb15a517b01a2bcf583a18ebf5d72a84e6064c417e64b"},
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cf936ba67bc6c734f3aa1c01391da74ab7fc046a9f8bbfa230b8393b90cf472"},
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1aab176dd55b59f77a63b27cffaca67d29987d91a5b615cbead41331e6b7428"},
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:995d0759004c08abd5d1b81300a91d18c8577c6389300bed1c7c11675105a44d"},
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bc22e00edeb068f71967ab99081e9406cd56dbed864fc3a8259442999d71552"},
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35b4f7842154176523e0a63c9b871168c69b98065d05a4f637fce342a6a2693a"},
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7ace71c4b7a0c41f317ae24be62bb61e9d80838d38acb20e70697c625e71f120"},
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8f639e3f5795a6568aa4f7d2ac6057c757dcd187593679f035adbf12b892bb00"},
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e8be3aff14f0120ad049121322b107f8a759be76a6a62138322d4c8a337a9e2c"},
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:122d8e7986043d0549e9eb23c7fd23be078be4b70c9eb42a20052b3d3149c6f2"},
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fd9c227990f609c165f56b46107d0bc34553fe0387818c42c02f77974402c36"},
{file = "yarl-1.16.0-cp313-cp313-win32.whl", hash = "sha256:595ca5e943baed31d56b33b34736461a371c6ea0038d3baec399949dd628560b"},
{file = "yarl-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:921b81b8d78f0e60242fb3db615ea3f368827a76af095d5a69f1c3366db3f596"},
{file = "yarl-1.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab2b2ac232110a1fdb0d3ffcd087783edd3d4a6ced432a1bf75caf7b7be70916"},
{file = "yarl-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f8713717a09acbfee7c47bfc5777e685539fefdd34fa72faf504c8be2f3df4e"},
{file = "yarl-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdcffe1dbcb4477d2b4202f63cd972d5baa155ff5a3d9e35801c46a415b7f71a"},
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a91217208306d82357c67daeef5162a41a28c8352dab7e16daa82e3718852a7"},
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ab3ed42c78275477ea8e917491365e9a9b69bb615cb46169020bd0aa5e2d6d3"},
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:707ae579ccb3262dfaef093e202b4c3fb23c3810e8df544b1111bd2401fd7b09"},
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7a852d1cd0b8d8b37fc9d7f8581152add917a98cfe2ea6e241878795f917ae"},
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3f1cc3d3d4dc574bebc9b387f6875e228ace5748a7c24f49d8f01ac1bc6c31b"},
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5ff96da263740779b0893d02b718293cc03400c3a208fc8d8cd79d9b0993e532"},
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3d375a19ba2bfe320b6d873f3fb165313b002cef8b7cc0a368ad8b8a57453837"},
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:62c7da0ad93a07da048b500514ca47b759459ec41924143e2ddb5d7e20fd3db5"},
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:147b0fcd0ee33b4b5f6edfea80452d80e419e51b9a3f7a96ce98eaee145c1581"},
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:504e1fe1cc4f170195320eb033d2b0ccf5c6114ce5bf2f617535c01699479bca"},
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bdcf667a5dec12a48f669e485d70c54189f0639c2157b538a4cffd24a853624f"},
{file = "yarl-1.16.0-cp39-cp39-win32.whl", hash = "sha256:e9951afe6557c75a71045148890052cb942689ee4c9ec29f5436240e1fcc73b7"},
{file = "yarl-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d7aaa8ff95d0840e289423e7dc35696c2b058d635f945bf05b5cd633146b027"},
{file = "yarl-1.16.0-py3-none-any.whl", hash = "sha256:e6980a558d8461230c457218bd6c92dfc1d10205548215c2c21d79dc8d0a96f3"},
{file = "yarl-1.16.0.tar.gz", hash = "sha256:b6f687ced5510a9a2474bbae96a4352e5ace5fa34dc44a217b0537fec1db00b4"},
{file = "yarl-1.17.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91"},
{file = "yarl-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da"},
{file = "yarl-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec"},
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21"},
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948"},
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04"},
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3"},
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d"},
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba"},
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17"},
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5"},
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822"},
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f"},
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931"},
{file = "yarl-1.17.1-cp310-cp310-win32.whl", hash = "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b"},
{file = "yarl-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243"},
{file = "yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217"},
{file = "yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988"},
{file = "yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75"},
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca"},
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74"},
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f"},
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d"},
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11"},
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0"},
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3"},
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe"},
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860"},
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4"},
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4"},
{file = "yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7"},
{file = "yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3"},
{file = "yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61"},
{file = "yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d"},
{file = "yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139"},
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5"},
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac"},
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463"},
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147"},
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7"},
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685"},
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172"},
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7"},
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da"},
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c"},
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199"},
{file = "yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96"},
{file = "yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df"},
{file = "yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488"},
{file = "yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374"},
{file = "yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac"},
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170"},
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8"},
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938"},
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e"},
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556"},
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67"},
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8"},
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3"},
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0"},
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299"},
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258"},
{file = "yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2"},
{file = "yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda"},
{file = "yarl-1.17.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6"},
{file = "yarl-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159"},
{file = "yarl-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95"},
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004"},
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383"},
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e"},
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934"},
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c"},
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29"},
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71"},
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55"},
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857"},
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5"},
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f"},
{file = "yarl-1.17.1-cp39-cp39-win32.whl", hash = "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473"},
{file = "yarl-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138"},
{file = "yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06"},
{file = "yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47"},
]
[package.dependencies]
@ -4298,4 +4313,4 @@ type = ["pytest-mypy"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "8d540e367903004280ea01872d13776353e3a2dc144e6568267da01f35026bf0"
content-hash = "a654acc8816c96d1ec25bce16fe1afdd8d37e5ac38a0fa59ee7871dde5ca5229"

View file

@ -37,10 +37,11 @@ sqlmodel = "^0.0.19"
tiktoken = "^0.7.0"
uvicorn = "0.30.1"
typer = "^0.12.5"
chromadb = "0.5.11"
chromadb = "0.5.16"
alembic = "^1.13.2"
alembic-postgresql-enum = "^1.2.0"
sqlalchemy-utils = "^0.41.2"
stripe = "^11.1.1"
[build-system]
build-backend = "poetry.core.masonry.api"

View file

@ -1,26 +1,54 @@
import logging
import os
import importlib
from config.config import get_learnhouse_config
from fastapi import FastAPI
from sqlmodel import SQLModel, Session, create_engine
def import_all_models():
base_dir = 'src/db'
base_module_path = 'src.db'
# Recursively walk through the base directory
for root, dirs, files in os.walk(base_dir):
# Filter out __init__.py and non-Python files
module_files = [f for f in files if f.endswith('.py') and f != '__init__.py']
# Calculate the module's base path from its directory structure
path_diff = os.path.relpath(root, base_dir)
if path_diff == '.':
current_module_base = base_module_path
else:
current_module_base = f"{base_module_path}.{path_diff.replace(os.sep, '.')}"
# Dynamically import each module
for file_name in module_files:
module_name = file_name[:-3] # Remove the '.py' extension
full_module_path = f"{current_module_base}.{module_name}"
importlib.import_module(full_module_path)
# Import all models before creating engine
import_all_models()
learnhouse_config = get_learnhouse_config()
engine = create_engine(
learnhouse_config.database_config.sql_connection_string, echo=False, pool_pre_ping=True # type: ignore
learnhouse_config.database_config.sql_connection_string, # type: ignore
echo=False,
pool_pre_ping=True # type: ignore
)
SQLModel.metadata.create_all(engine)
# Create all tables after importing all models
SQLModel.metadata.create_all(engine)
async def connect_to_db(app: FastAPI):
app.db_engine = engine # type: ignore
logging.info("LearnHouse database has been started.")
SQLModel.metadata.create_all(engine)
def get_db_session():
with Session(engine) as session:
yield session
async def close_database(app: FastAPI):
logging.info("LearnHouse has been shut down.")
return app

View file

@ -30,7 +30,7 @@ class CollectionUpdate(CollectionBase):
courses: Optional[list]
name: Optional[str]
public: Optional[bool]
description: Optional[str]
description: Optional[str] = ""
class CollectionRead(CollectionBase):

View file

@ -40,7 +40,6 @@ class AssignmentOrgConfig(BaseModel):
class PaymentOrgConfig(BaseModel):
enabled: bool = True
stripe_key: str = ""
class DiscussionOrgConfig(BaseModel):
@ -91,7 +90,7 @@ class OrgCloudConfig(BaseModel):
# Main Config
class OrganizationConfigBase(BaseModel):
config_version: str = "1.1"
config_version: str = "1.2"
general: OrgGeneralConfig
features: OrgFeatureConfig
cloud: OrgCloudConfig

View file

@ -0,0 +1,46 @@
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import JSON
from sqlmodel import Field, SQLModel, Column, BigInteger, ForeignKey
# PaymentsConfig
class PaymentProviderEnum(str, Enum):
STRIPE = "stripe"
class PaymentsConfigBase(SQLModel):
enabled: bool = True
active: bool = False
provider: PaymentProviderEnum = PaymentProviderEnum.STRIPE
provider_specific_id: str | None = None
provider_config: dict = Field(default={}, sa_column=Column(JSON))
class PaymentsConfig(PaymentsConfigBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))
)
creation_date: datetime = Field(default=datetime.now())
update_date: datetime = Field(default=datetime.now())
class PaymentsConfigCreate(PaymentsConfigBase):
pass
class PaymentsConfigUpdate(PaymentsConfigBase):
enabled: Optional[bool] = True
provider_config: Optional[dict] = None
provider_specific_id: Optional[str] = None
class PaymentsConfigRead(PaymentsConfigBase):
id: int
org_id: int
creation_date: datetime
update_date: datetime
class PaymentsConfigDelete(SQLModel):
id: int

View file

@ -0,0 +1,15 @@
from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey
from typing import Optional
from datetime import datetime
class PaymentsCourseBase(SQLModel):
course_id: int = Field(sa_column=Column(BigInteger, ForeignKey("course.id", ondelete="CASCADE")))
class PaymentsCourse(PaymentsCourseBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
payment_product_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsproduct.id", ondelete="CASCADE")))
org_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))
)
creation_date: datetime = Field(default=datetime.now())
update_date: datetime = Field(default=datetime.now())

View file

@ -0,0 +1,44 @@
from enum import Enum
from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, String
from typing import Optional
from datetime import datetime
class PaymentProductTypeEnum(str, Enum):
SUBSCRIPTION = "subscription"
ONE_TIME = "one_time"
class PaymentPriceTypeEnum(str, Enum):
CUSTOMER_CHOICE = "customer_choice"
FIXED_PRICE = "fixed_price"
class PaymentsProductBase(SQLModel):
name: str = ""
description: Optional[str] = ""
product_type: PaymentProductTypeEnum = PaymentProductTypeEnum.ONE_TIME
price_type: PaymentPriceTypeEnum = PaymentPriceTypeEnum.FIXED_PRICE
benefits: str = ""
amount: float = 0.0
currency: str = "USD"
class PaymentsProduct(PaymentsProductBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
org_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))
)
payments_config_id: int = Field(sa_column=Column(BigInteger, ForeignKey("paymentsconfig.id", ondelete="CASCADE")))
provider_product_id: str = Field(sa_column=Column(String))
creation_date: datetime = Field(default=datetime.now())
update_date: datetime = Field(default=datetime.now())
class PaymentsProductCreate(PaymentsProductBase):
pass
class PaymentsProductUpdate(PaymentsProductBase):
pass
class PaymentsProductRead(PaymentsProductBase):
id: int
org_id: int
payments_config_id: int
creation_date: datetime
update_date: datetime

View file

@ -0,0 +1,37 @@
from openai import BaseModel
from sqlmodel import SQLModel, Field, Column, BigInteger, ForeignKey, JSON
from typing import Optional
from datetime import datetime
from enum import Enum
class PaymentStatusEnum(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
ACTIVE = "active"
CANCELLED = "cancelled"
FAILED = "failed"
REFUNDED = "refunded"
class ProviderSpecificData(BaseModel):
stripe_customer: dict | None = None
custom_customer: dict | None = None
class PaymentsUserBase(SQLModel):
status: PaymentStatusEnum = PaymentStatusEnum.PENDING
provider_specific_data: dict = Field(default={}, sa_column=Column(JSON))
class PaymentsUser(PaymentsUserBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("user.id", ondelete="CASCADE"))
)
org_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("organization.id", ondelete="CASCADE"))
)
payment_product_id: int = Field(
sa_column=Column(BigInteger, ForeignKey("paymentsproduct.id", ondelete="CASCADE"))
)
creation_date: datetime = Field(default=datetime.now())
update_date: datetime = Field(default=datetime.now())

View file

@ -59,6 +59,11 @@ class AnonymousUser(SQLModel):
user_uuid: str = "user_anonymous"
username: str = "anonymous"
class InternalUser(SQLModel):
id: int = 0
user_uuid: str = "user_internal"
username: str = "internal"
class User(UserBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True)

View file

@ -1,11 +1,12 @@
import os
from fastapi import APIRouter, Depends
from src.routers import health
from src.routers import usergroups
from src.routers import dev, trail, users, auth, orgs, roles
from src.routers.ai import ai
from src.routers.courses import chapters, collections, courses, assignments
from src.routers.courses.activities import activities, blocks
from src.routers.ee import cloud_internal
from src.routers.ee import cloud_internal, payments
from src.routers.install import install
from src.services.dev.dev import isDevModeEnabledOrRaise
from src.services.install.install import isInstallModeEnabled
@ -32,6 +33,7 @@ v1_router.include_router(
)
v1_router.include_router(trail.router, prefix="/trail", tags=["trail"])
v1_router.include_router(ai.router, prefix="/ai", tags=["ai"])
v1_router.include_router(payments.router, prefix="/payments", tags=["payments"])
if os.environ.get("CLOUD_INTERNAL_KEY"):
v1_router.include_router(
@ -41,6 +43,8 @@ if os.environ.get("CLOUD_INTERNAL_KEY"):
dependencies=[Depends(cloud_internal.check_internal_cloud_key)],
)
v1_router.include_router(health.router, prefix="/health", tags=["health"])
# Dev Routes
v1_router.include_router(
dev.router,

View file

@ -0,0 +1,265 @@
from typing import Literal
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from src.core.events.database import get_db_session
from src.db.payments.payments import PaymentsConfig, PaymentsConfigRead
from src.db.users import PublicUser
from src.security.auth import get_current_user
from src.services.payments.payments_config import (
init_payments_config,
get_payments_config,
delete_payments_config,
)
from src.db.payments.payments_products import PaymentsProductCreate, PaymentsProductRead, PaymentsProductUpdate
from src.services.payments.payments_products import create_payments_product, delete_payments_product, get_payments_product, get_products_by_course, list_payments_products, update_payments_product
from src.services.payments.payments_courses import (
link_course_to_product,
unlink_course_from_product,
get_courses_by_product,
)
from src.services.payments.payments_users import get_owned_courses
from src.services.payments.payments_stripe import create_checkout_session, handle_stripe_oauth_callback, update_stripe_account_id
from src.services.payments.payments_access import check_course_paid_access
from src.services.payments.payments_customers import get_customers
from src.services.payments.payments_stripe import generate_stripe_connect_link
from src.services.payments.webhooks.payments_webhooks import handle_stripe_webhook
router = APIRouter()
@router.post("/{org_id}/config")
async def api_create_payments_config(
request: Request,
org_id: int,
provider: Literal["stripe"],
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> PaymentsConfig:
return await init_payments_config(request, org_id, provider, current_user, db_session)
@router.get("/{org_id}/config")
async def api_get_payments_config(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> list[PaymentsConfigRead]:
return await get_payments_config(request, org_id, current_user, db_session)
@router.delete("/{org_id}/config")
async def api_delete_payments_config(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
await delete_payments_config(request, org_id, current_user, db_session)
return {"message": "Payments config deleted successfully"}
@router.post("/{org_id}/products")
async def api_create_payments_product(
request: Request,
org_id: int,
payments_product: PaymentsProductCreate,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> PaymentsProductRead:
return await create_payments_product(request, org_id, payments_product, current_user, db_session)
@router.get("/{org_id}/products")
async def api_get_payments_products(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> list[PaymentsProductRead]:
return await list_payments_products(request, org_id, current_user, db_session)
@router.get("/{org_id}/products/{product_id}")
async def api_get_payments_product(
request: Request,
org_id: int,
product_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> PaymentsProductRead:
return await get_payments_product(request, org_id, product_id, current_user, db_session)
@router.put("/{org_id}/products/{product_id}")
async def api_update_payments_product(
request: Request,
org_id: int,
product_id: int,
payments_product: PaymentsProductUpdate,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
) -> PaymentsProductRead:
return await update_payments_product(request, org_id, product_id, payments_product, current_user, db_session)
@router.delete("/{org_id}/products/{product_id}")
async def api_delete_payments_product(
request: Request,
org_id: int,
product_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
await delete_payments_product(request, org_id, product_id, current_user, db_session)
return {"message": "Payments product deleted successfully"}
@router.post("/{org_id}/products/{product_id}/courses/{course_id}")
async def api_link_course_to_product(
request: Request,
org_id: int,
product_id: int,
course_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await link_course_to_product(
request, org_id, course_id, product_id, current_user, db_session
)
@router.delete("/{org_id}/products/{product_id}/courses/{course_id}")
async def api_unlink_course_from_product(
request: Request,
org_id: int,
product_id: int,
course_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await unlink_course_from_product(
request, org_id, course_id, current_user, db_session
)
@router.get("/{org_id}/products/{product_id}/courses")
async def api_get_courses_by_product(
request: Request,
org_id: int,
product_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await get_courses_by_product(
request, org_id, product_id, current_user, db_session
)
@router.get("/{org_id}/courses/{course_id}/products")
async def api_get_products_by_course(
request: Request,
org_id: int,
course_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await get_products_by_course(
request, org_id, course_id, current_user, db_session
)
# Payments webhooks
@router.post("/stripe/webhook")
async def api_handle_connected_accounts_stripe_webhook(
request: Request,
db_session: Session = Depends(get_db_session),
):
return await handle_stripe_webhook(request, "standard", db_session)
@router.post("/stripe/webhook/connect")
async def api_handle_connected_accounts_stripe_webhook_connect(
request: Request,
db_session: Session = Depends(get_db_session),
):
return await handle_stripe_webhook(request, "connect", db_session)
# Payments checkout
@router.post("/{org_id}/stripe/checkout/product/{product_id}")
async def api_create_checkout_session(
request: Request,
org_id: int,
product_id: int,
redirect_uri: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await create_checkout_session(request, org_id, product_id, redirect_uri, current_user, db_session)
@router.get("/{org_id}/courses/{course_id}/access")
async def api_check_course_paid_access(
request: Request,
org_id: int,
course_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Check if current user has paid access to a specific course
"""
return {
"has_access": await check_course_paid_access(
course_id=course_id,
user=current_user,
db_session=db_session
)
}
@router.get("/{org_id}/customers")
async def api_get_customers(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Get list of customers and their subscriptions for an organization
"""
return await get_customers(request, org_id, current_user, db_session)
@router.get("/{org_id}/courses/owned")
async def api_get_owned_courses(
request: Request,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await get_owned_courses(request, current_user, db_session)
@router.put("/{org_id}/stripe/account")
async def api_update_stripe_account_id(
request: Request,
org_id: int,
stripe_account_id: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await update_stripe_account_id(
request, org_id, stripe_account_id, current_user, db_session
)
@router.post("/{org_id}/stripe/connect/link")
async def api_generate_stripe_connect_link(
request: Request,
org_id: int,
redirect_uri: str,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
"""
Generate a Stripe OAuth link for connecting a Stripe account
"""
return await generate_stripe_connect_link(
request, org_id, redirect_uri, current_user, db_session
)
@router.get("/stripe/oauth/callback")
async def stripe_oauth_callback(
request: Request,
code: str,
org_id: int,
current_user: PublicUser = Depends(get_current_user),
db_session: Session = Depends(get_db_session),
):
return await handle_stripe_oauth_callback(request, org_id, code, current_user, db_session)

View file

@ -0,0 +1,11 @@
from fastapi import Depends, APIRouter
from sqlmodel import Session
from src.services.health.health import check_health
from src.core.events.database import get_db_session
router = APIRouter()
@router.get("")
async def health(db_session: Session = Depends(get_db_session)):
return await check_health(db_session)

View file

@ -142,7 +142,7 @@ async def authorization_verify_based_on_org_admin_status(
# Tested and working
async def authorization_verify_based_on_roles_and_authorship_and_usergroups(
async def authorization_verify_based_on_roles_and_authorship(
request: Request,
user_id: int,
action: Literal["read", "update", "delete", "create"],

View file

@ -3,7 +3,7 @@ from sqlmodel import Session, select
from src.db.courses.courses import Course
from src.db.courses.chapters import Chapter
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
@ -14,6 +14,8 @@ from fastapi import HTTPException, Request
from uuid import uuid4
from datetime import datetime
from src.services.payments.payments_access import check_activity_paid_access
####################################################
# CRUD
@ -112,7 +114,16 @@ async def get_activity(
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
activity = ActivityRead.model_validate(activity)
# Paid access check
has_paid_access = await check_activity_paid_access(
activity_id=activity.id if activity.id else 0,
user=current_user,
db_session=db_session
)
activity_read = ActivityRead.model_validate(activity)
activity_read.content = activity_read.content if has_paid_access else { "paid_access": False }
activity = activity_read
return activity
@ -258,30 +269,32 @@ async def get_activities(
async def rbac_check(
request: Request,
course_uuid: str,
element_uuid: str,
current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
if action == "read":
if current_user.id == 0: # Anonymous user
res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session
request, element_uuid, action, db_session
)
return res
else:
res = await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, current_user.id, action, course_uuid, db_session
res = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, element_uuid, db_session
)
return res
else:
# For non-read actions, proceed with regular RBAC checks
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,
course_uuid,
element_uuid,
db_session,
)

View file

@ -34,7 +34,7 @@ from src.security.features_utils.usage import (
increase_feature_usage,
)
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
@ -1666,7 +1666,7 @@ async def rbac_check(
return res
else:
res = (
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
)
@ -1674,7 +1674,7 @@ async def rbac_check(
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,

View file

@ -3,7 +3,7 @@ from src.db.courses.courses import Course
from src.db.organizations import Organization
from sqlmodel import Session, select
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
from src.db.courses.chapters import Chapter
@ -150,7 +150,7 @@ async def rbac_check(
):
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,

View file

@ -5,7 +5,7 @@ from src.db.organizations import Organization
from pydantic import BaseModel
from sqlmodel import Session, select
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
from src.db.courses.chapters import Chapter
@ -232,7 +232,7 @@ async def rbac_check(
):
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,

View file

@ -4,7 +4,7 @@ from uuid import uuid4
from sqlmodel import Session, select
from src.db.users import AnonymousUser
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
@ -561,14 +561,14 @@ async def rbac_check(
)
return res
else:
res = await authorization_verify_based_on_roles_and_authorship_and_usergroups(
res = await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
return res
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,

View file

@ -4,7 +4,7 @@ from uuid import uuid4
from sqlmodel import Session, select
from src.db.users import AnonymousUser
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
@ -300,7 +300,7 @@ async def rbac_check(
)
else:
res = (
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, collection_uuid, db_session
)
)
@ -308,7 +308,7 @@ async def rbac_check(
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,

View file

@ -1,7 +1,6 @@
from typing import Literal
from typing import Literal, List
from uuid import uuid4
from sqlalchemy import union
from sqlmodel import Session, select
from sqlmodel import Session, select, or_, and_
from src.db.usergroup_resources import UserGroupResource
from src.db.usergroup_user import UserGroupUser
from src.db.organizations import Organization
@ -21,7 +20,7 @@ from src.db.courses.courses import (
FullCourseReadWithTrail,
)
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_element_is_public,
authorization_verify_if_user_is_anon,
)
@ -151,6 +150,69 @@ async def get_course_meta(
trail=trail if trail else None,
)
async def get_courses_orgslug(
request: Request,
current_user: PublicUser | AnonymousUser,
org_slug: str,
db_session: Session,
page: int = 1,
limit: int = 10,
) -> List[CourseRead]:
offset = (page - 1) * limit
# Base query
query = (
select(Course)
.join(Organization)
.where(Organization.slug == org_slug)
)
if isinstance(current_user, AnonymousUser):
# For anonymous users, only show public courses
query = query.where(Course.public == True)
else:
# For authenticated users, show:
# 1. Public courses
# 2. Courses not in any UserGroup
# 3. Courses in UserGroups where the user is a member
# 4. Courses where the user is a resource author
query = (
query
.outerjoin(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid) # type: ignore
.outerjoin(UserGroupUser, and_(
UserGroupUser.usergroup_id == UserGroupResource.usergroup_id,
UserGroupUser.user_id == current_user.id
))
.outerjoin(ResourceAuthor, ResourceAuthor.resource_uuid == Course.course_uuid) # type: ignore
.where(or_(
Course.public == True,
UserGroupResource.resource_uuid == None, # Courses not in any UserGroup # noqa: E711
UserGroupUser.user_id == current_user.id, # Courses in UserGroups where user is a member
ResourceAuthor.user_id == current_user.id # Courses where user is a resource author
))
)
# Apply pagination
query = query.offset(offset).limit(limit).distinct()
courses = db_session.exec(query).all()
# Fetch authors for each course
course_reads = []
for course in courses:
authors_query = (
select(User)
.join(ResourceAuthor, ResourceAuthor.user_id == User.id) # type: ignore
.where(ResourceAuthor.resource_uuid == course.course_uuid)
)
authors = db_session.exec(authors_query).all()
course_read = CourseRead.model_validate(course)
course_read.authors = [UserRead.model_validate(author) for author in authors]
course_reads.append(course_read)
return course_reads
async def create_course(
request: Request,
@ -366,72 +428,7 @@ async def delete_course(
return {"detail": "Course deleted"}
async def get_courses_orgslug(
request: Request,
current_user: PublicUser | AnonymousUser,
org_slug: str,
db_session: Session,
page: int = 1,
limit: int = 10,
):
# TODO : This entire function is a mess. It needs to be rewritten.
# Query for public courses
statement_public = (
select(Course)
.join(Organization)
.where(Organization.slug == org_slug, Course.public == True)
)
# Query for courses where the current user is an author
statement_author = (
select(Course)
.join(Organization)
.join(ResourceAuthor, ResourceAuthor.user_id == current_user.id) # type: ignore
.where(
Organization.slug == org_slug,
ResourceAuthor.resource_uuid == Course.course_uuid,
)
)
# Query for courses where the current user is in a user group that has access to the course
statement_usergroup = (
select(Course)
.join(Organization)
.join(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid) # type: ignore
.join(
UserGroupUser, UserGroupUser.usergroup_id == UserGroupResource.usergroup_id # type: ignore
)
.where(Organization.slug == org_slug, UserGroupUser.user_id == current_user.id)
)
# Combine the results
statement_complete = union(
statement_public, statement_author, statement_usergroup
).subquery()
# TODO: migrate this to exec
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 = (
select(User)
.join(ResourceAuthor)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
)
authors = db_session.exec(authors_statement).all()
# convert from User to UserRead
authors = [UserRead.model_validate(author) for author in authors]
course.authors = authors
return courses
## 🔒 RBAC Utils ##
@ -452,7 +449,7 @@ async def rbac_check(
return res
else:
res = (
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, course_uuid, db_session
)
)
@ -460,7 +457,7 @@ async def rbac_check(
else:
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,

View file

View file

@ -0,0 +1,21 @@
from fastapi import HTTPException
from sqlmodel import Session, select
from src.db.organizations import Organization
async def check_database_health(db_session: Session) -> bool:
statement = select(Organization)
result = db_session.exec(statement)
if not result:
return False
return True
async def check_health(db_session: Session) -> bool:
# Check database health
database_healthy = await check_database_health(db_session)
if not database_healthy:
raise HTTPException(status_code=503, detail="Database is not healthy")
return True

View file

@ -330,7 +330,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess
# Org Config
org_config = OrganizationConfigBase(
config_version="1.1",
config_version="1.2",
general=OrgGeneralConfig(
enabled=True,
color="normal",
@ -345,7 +345,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess
storage=StorageOrgConfig(enabled=True, limit=0),
ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"),
assignments=AssignmentOrgConfig(enabled=True, limit=0),
payments=PaymentOrgConfig(enabled=True, stripe_key=""),
payments=PaymentOrgConfig(enabled=False),
discussions=DiscussionOrgConfig(enabled=True, limit=0),
analytics=AnalyticsOrgConfig(enabled=True, limit=0),
collaboration=CollaborationOrgConfig(enabled=True, limit=0),

View file

@ -26,7 +26,7 @@ from src.security.rbac.rbac import (
authorization_verify_based_on_org_admin_status,
authorization_verify_if_user_is_anon,
)
from src.db.users import AnonymousUser, PublicUser
from src.db.users import AnonymousUser, InternalUser, PublicUser
from src.db.user_organizations import UserOrganization
from src.db.organizations import (
Organization,
@ -682,7 +682,7 @@ async def get_org_join_mechanism(
async def rbac_check(
request: Request,
org_uuid: str,
current_user: PublicUser | AnonymousUser,
current_user: PublicUser | AnonymousUser | InternalUser,
action: Literal["create", "read", "update", "delete"],
db_session: Session,
):
@ -690,6 +690,10 @@ async def rbac_check(
if action == "read":
return True
# Internal users can do anything
if isinstance(current_user, InternalUser):
return True
else:
isUserAnon = await authorization_verify_if_user_is_anon(current_user.id)

View file

@ -0,0 +1,98 @@
from sqlmodel import Session, select
from src.db.payments.payments_users import PaymentStatusEnum, PaymentsUser
from src.db.users import PublicUser, AnonymousUser
from src.db.payments.payments_courses import PaymentsCourse
from src.db.courses.activities import Activity
from src.db.courses.courses import Course
from fastapi import HTTPException
async def check_activity_paid_access(
activity_id: int,
user: PublicUser | AnonymousUser,
db_session: Session,
) -> bool:
"""
Check if a user has access to a specific activity
Returns True if:
- User is an author of the course
- Activity is in a free course
- User has a valid subscription for the course
"""
# Get activity and associated course
statement = select(Activity).where(Activity.id == activity_id)
activity = db_session.exec(statement).first()
if not activity:
raise HTTPException(status_code=404, detail="Activity not found")
# Check if course exists
statement = select(Course).where(Course.id == activity.course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(status_code=404, detail="Course not found")
# Check if course is linked to a product
statement = select(PaymentsCourse).where(PaymentsCourse.course_id == course.id)
course_payment = db_session.exec(statement).first()
# If course is not linked to any product, it's free
if not course_payment:
return True
# Anonymous users have no access to paid activities
if isinstance(user, AnonymousUser):
return False
# Check if user has a valid subscription or payment
statement = select(PaymentsUser).where(
PaymentsUser.user_id == user.id,
PaymentsUser.payment_product_id == course_payment.payment_product_id,
PaymentsUser.status.in_( # type: ignore
[PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]
),
)
access = db_session.exec(statement).first()
return bool(access)
async def check_course_paid_access(
course_id: int,
user: PublicUser | AnonymousUser,
db_session: Session,
) -> bool:
"""
Check if a user has paid access to a specific course
Returns True if:
- User is an author of the course
- Course is free (not linked to any product)
- User has a valid subscription for the course
"""
# Check if course exists
statement = select(Course).where(Course.id == course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(status_code=404, detail="Course not found")
# Check if course is linked to a product
statement = select(PaymentsCourse).where(PaymentsCourse.course_id == course.id)
course_payment = db_session.exec(statement).first()
# If course is not linked to any product, it's free
if not course_payment:
return True
# Check if user has a valid subscription
statement = select(PaymentsUser).where(
PaymentsUser.user_id == user.id,
PaymentsUser.payment_product_id == course_payment.payment_product_id,
PaymentsUser.status.in_( # type: ignore
[PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]
),
)
subscription = db_session.exec(statement).first()
return bool(subscription)

View file

@ -0,0 +1,139 @@
from typing import Literal
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from src.db.payments.payments import (
PaymentProviderEnum,
PaymentsConfig,
PaymentsConfigUpdate,
PaymentsConfigRead,
)
from src.db.users import PublicUser, AnonymousUser, InternalUser
from src.db.organizations import Organization
from src.services.orgs.orgs import rbac_check
async def init_payments_config(
request: Request,
org_id: int,
provider: Literal["stripe"],
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> PaymentsConfig:
# Validate organization exists
org = db_session.exec(
select(Organization).where(Organization.id == org_id)
).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# Verify permissions
await rbac_check(request, org.org_uuid, current_user, "create", db_session)
# Check for existing config
existing_config = db_session.exec(
select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
).first()
if existing_config:
raise HTTPException(
status_code=409,
detail="Payments config already exists for this organization"
)
# Initialize new config
new_config = PaymentsConfig(
org_id=org_id,
provider=PaymentProviderEnum.STRIPE,
provider_config={
"onboarding_completed": False,
},
provider_specific_id=None
)
# Save to database
db_session.add(new_config)
db_session.commit()
db_session.refresh(new_config)
return new_config
async def get_payments_config(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
) -> list[PaymentsConfigRead]:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Get payments config
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
configs = db_session.exec(statement).all()
return [PaymentsConfigRead.model_validate(config) for config in configs]
async def update_payments_config(
request: Request,
org_id: int,
payments_config: PaymentsConfigUpdate,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
) -> PaymentsConfig:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Get existing payments config
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
config = db_session.exec(statement).first()
if not config:
raise HTTPException(status_code=404, detail="Payments config not found")
# Update config
for key, value in payments_config.model_dump().items():
setattr(config, key, value)
db_session.add(config)
db_session.commit()
db_session.refresh(config)
return config
async def delete_payments_config(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> None:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "delete", db_session)
# Get existing payments config
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
config = db_session.exec(statement).first()
if not config:
raise HTTPException(status_code=404, detail="Payments config not found")
# Delete config
db_session.delete(config)
db_session.commit()

View file

@ -0,0 +1,123 @@
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from src.db.payments.payments_courses import PaymentsCourse
from src.db.payments.payments_products import PaymentsProduct
from src.db.courses.courses import Course
from src.db.users import PublicUser, AnonymousUser
from src.services.courses.courses import rbac_check
async def link_course_to_product(
request: Request,
org_id: int,
course_id: int,
product_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Check if course exists and user has permission
statement = select(Course).where(Course.id == course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(status_code=404, detail="Course not found")
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Check if product exists
statement = select(PaymentsProduct).where(
PaymentsProduct.id == product_id,
PaymentsProduct.org_id == org_id
)
product = db_session.exec(statement).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# Check if course is already linked to another product
statement = select(PaymentsCourse).where(PaymentsCourse.course_id == course.id)
existing_link = db_session.exec(statement).first()
if existing_link:
raise HTTPException(
status_code=400,
detail="Course is already linked to a product"
)
# Create new payment course link
payment_course = PaymentsCourse(
course_id=course.id, # type: ignore
payment_product_id=product_id,
org_id=org_id,
)
db_session.add(payment_course)
db_session.commit()
return {"message": "Course linked to product successfully"}
async def unlink_course_from_product(
request: Request,
org_id: int,
course_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Check if course exists and user has permission
statement = select(Course).where(Course.id == course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(status_code=404, detail="Course not found")
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "update", db_session)
# Find and delete the payment course link
statement = select(PaymentsCourse).where(
PaymentsCourse.course_id == course.id,
PaymentsCourse.org_id == org_id
)
payment_course = db_session.exec(statement).first()
if not payment_course:
raise HTTPException(
status_code=404,
detail="Course is not linked to any product"
)
db_session.delete(payment_course)
db_session.commit()
return {"message": "Course unlinked from product successfully"}
async def get_courses_by_product(
request: Request,
org_id: int,
product_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Check if product exists
statement = select(PaymentsProduct).where(
PaymentsProduct.id == product_id,
PaymentsProduct.org_id == org_id
)
product = db_session.exec(statement).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
# Get all courses linked to this product with explicit join
statement = (
select(Course)
.select_from(Course)
.join(PaymentsCourse, Course.id == PaymentsCourse.course_id) # type: ignore
.where(
PaymentsCourse.payment_product_id == product_id,
PaymentsCourse.org_id == org_id
)
)
courses = db_session.exec(statement).all()
return courses

View file

@ -0,0 +1,50 @@
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from src.db.organizations import Organization
from src.db.users import PublicUser, AnonymousUser
from src.db.payments.payments_users import PaymentsUser
from src.services.orgs.orgs import rbac_check
from src.services.payments.payments_products import get_payments_product
from src.services.users.users import read_user_by_id
async def get_customers(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Get all payment users for the organization
statement = select(PaymentsUser).where(PaymentsUser.org_id == org_id)
payment_users = db_session.exec(statement).all()
customers_data = []
for payment_user in payment_users:
# Get user data
user = await read_user_by_id(request, db_session, current_user, payment_user.user_id)
# Get product data
if org.id is None:
raise HTTPException(status_code=400, detail="Invalid organization ID")
product = await get_payments_product(request, org.id, payment_user.payment_product_id, current_user, db_session)
customer_data = {
'payment_user_id': payment_user.id,
'user': user if user else None,
'product': product if product else None,
'status': payment_user.status,
'creation_date': payment_user.creation_date,
'update_date': payment_user.update_date
}
customers_data.append(customer_data)
return customers_data

View file

@ -0,0 +1,216 @@
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from src.db.courses.courses import Course
from src.db.payments.payments import PaymentsConfig
from src.db.payments.payments_courses import PaymentsCourse
from src.db.payments.payments_products import (
PaymentsProduct,
PaymentsProductCreate,
PaymentsProductUpdate,
PaymentsProductRead,
)
from src.db.payments.payments_users import PaymentStatusEnum, PaymentsUser
from src.db.users import PublicUser, AnonymousUser
from src.db.organizations import Organization
from src.services.orgs.orgs import rbac_check
from datetime import datetime
from src.services.payments.payments_stripe import archive_stripe_product, create_stripe_product, update_stripe_product
async def create_payments_product(
request: Request,
org_id: int,
payments_product: PaymentsProductCreate,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> PaymentsProductRead:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "create", db_session)
# Check if payments config exists, has a valid id, and is active
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
config = db_session.exec(statement).first()
if not config or config.id is None:
raise HTTPException(status_code=404, detail="Valid payments config not found")
if not config.active:
raise HTTPException(status_code=400, detail="Payments config is not active")
# Create new payments product
new_product = PaymentsProduct(**payments_product.model_dump(), org_id=org_id, payments_config_id=config.id)
new_product.creation_date = datetime.now()
new_product.update_date = datetime.now()
# Create product in Stripe
stripe_product = await create_stripe_product(request, org_id, new_product, current_user, db_session)
new_product.provider_product_id = stripe_product.id
# Save to DB
db_session.add(new_product)
db_session.commit()
db_session.refresh(new_product)
return PaymentsProductRead.model_validate(new_product)
async def get_payments_product(
request: Request,
org_id: int,
product_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> PaymentsProductRead:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Get payments product
statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id)
product = db_session.exec(statement).first()
if not product:
raise HTTPException(status_code=404, detail="Payments product not found")
return PaymentsProductRead.model_validate(product)
async def update_payments_product(
request: Request,
org_id: int,
product_id: int,
payments_product: PaymentsProductUpdate,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> PaymentsProductRead:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Get existing payments product
statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id)
product = db_session.exec(statement).first()
if not product:
raise HTTPException(status_code=404, detail="Payments product not found")
# Update product
for key, value in payments_product.model_dump().items():
setattr(product, key, value)
product.update_date = datetime.now()
db_session.add(product)
db_session.commit()
db_session.refresh(product)
# Update product in Stripe
await update_stripe_product(request, org_id, product.provider_product_id, product, current_user, db_session)
return PaymentsProductRead.model_validate(product)
async def delete_payments_product(
request: Request,
org_id: int,
product_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> None:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "delete", db_session)
# Get existing payments product
statement = select(PaymentsProduct).where(PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id)
product = db_session.exec(statement).first()
if not product:
raise HTTPException(status_code=404, detail="Payments product not found")
# Check if there are any payment users linked to this product
statement = select(PaymentsUser).where(
PaymentsUser.payment_product_id == product_id,
PaymentsUser.status.in_([PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]) # type: ignore
)
payment_users = db_session.exec(statement).all()
if payment_users:
raise HTTPException(
status_code=400,
detail="Cannot delete product because users have paid access to it."
)
# Archive product in Stripe
await archive_stripe_product(request, org_id, product.provider_product_id, current_user, db_session)
# Delete product
db_session.delete(product)
db_session.commit()
async def list_payments_products(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> list[PaymentsProductRead]:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Get payments products ordered by id
statement = select(PaymentsProduct).where(PaymentsProduct.org_id == org_id).order_by(PaymentsProduct.id.desc()) # type: ignore
products = db_session.exec(statement).all()
return [PaymentsProductRead.model_validate(product) for product in products]
async def get_products_by_course(
request: Request,
org_id: int,
course_id: int,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> list[PaymentsProductRead]:
# Check if course exists and user has permission
statement = select(Course).where(Course.id == course_id)
course = db_session.exec(statement).first()
if not course:
raise HTTPException(status_code=404, detail="Course not found")
# RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
# Get all products linked to this course with explicit join
statement = (
select(PaymentsProduct)
.select_from(PaymentsProduct)
.join(PaymentsCourse, PaymentsProduct.id == PaymentsCourse.payment_product_id) # type: ignore
.where(
PaymentsCourse.course_id == course_id,
PaymentsCourse.org_id == org_id
)
)
products = db_session.exec(statement).all()
return [PaymentsProductRead.model_validate(product) for product in products]

View file

@ -0,0 +1,473 @@
import logging
from typing import Literal
from fastapi import HTTPException, Request
from sqlmodel import Session
import stripe
from config.config import get_learnhouse_config
from src.db.payments.payments import PaymentsConfigUpdate, PaymentsConfig
from src.db.payments.payments_products import (
PaymentPriceTypeEnum,
PaymentProductTypeEnum,
PaymentsProduct,
)
from src.db.payments.payments_users import PaymentStatusEnum
from src.db.users import AnonymousUser, InternalUser, PublicUser
from src.services.payments.payments_config import (
get_payments_config,
update_payments_config,
)
from sqlmodel import select
from src.services.payments.payments_users import (
create_payment_user,
delete_payment_user,
)
async def get_stripe_connected_account_id(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
# Get payments config
payments_config = await get_payments_config(request, org_id, current_user, db_session)
return payments_config[0].provider_specific_id
async def get_stripe_internal_credentials(
):
# Get payments config from config file
learnhouse_config = get_learnhouse_config()
if not learnhouse_config.payments_config.stripe.stripe_secret_key:
raise HTTPException(status_code=400, detail="Stripe secret key not configured")
if not learnhouse_config.payments_config.stripe.stripe_publishable_key:
raise HTTPException(
status_code=400, detail="Stripe publishable key not configured"
)
return {
"stripe_secret_key": learnhouse_config.payments_config.stripe.stripe_secret_key,
"stripe_publishable_key": learnhouse_config.payments_config.stripe.stripe_publishable_key,
"stripe_webhook_standard_secret": learnhouse_config.payments_config.stripe.stripe_webhook_standard_secret,
"stripe_webhook_connect_secret": learnhouse_config.payments_config.stripe.stripe_webhook_connect_secret,
}
async def create_stripe_product(
request: Request,
org_id: int,
product_data: PaymentsProduct,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_internal_credentials()
# Set the Stripe API key using the credentials
stripe.api_key = creds.get("stripe_secret_key")
# Prepare default_price_data based on price_type
if product_data.price_type == PaymentPriceTypeEnum.CUSTOMER_CHOICE:
default_price_data = {
"currency": product_data.currency,
"custom_unit_amount": {
"enabled": True,
"minimum": int(product_data.amount * 100), # Convert to cents
},
}
else:
default_price_data = {
"currency": product_data.currency,
"unit_amount": int(product_data.amount * 100), # Convert to cents
}
if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION:
default_price_data["recurring"] = {"interval": "month"}
stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session)
product = stripe.Product.create(
name=product_data.name,
description=product_data.description or "",
marketing_features=[
{"name": benefit.strip()}
for benefit in product_data.benefits.split(",")
if benefit.strip()
],
default_price_data=default_price_data, # type: ignore
stripe_account=stripe_acc_id,
)
return product
async def archive_stripe_product(
request: Request,
org_id: int,
product_id: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_internal_credentials()
# Set the Stripe API key using the credentials
stripe.api_key = creds.get("stripe_secret_key")
stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session)
try:
# Archive the product in Stripe
archived_product = stripe.Product.modify(product_id, active=False, stripe_account=stripe_acc_id)
return archived_product
except stripe.StripeError as e:
print(f"Error archiving Stripe product: {str(e)}")
raise HTTPException(
status_code=400, detail=f"Error archiving Stripe product: {str(e)}"
)
async def update_stripe_product(
request: Request,
org_id: int,
product_id: str,
product_data: PaymentsProduct,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
creds = await get_stripe_internal_credentials()
# Set the Stripe API key using the credentials
stripe.api_key = creds.get("stripe_secret_key")
stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session)
try:
# Create new price based on price_type
if product_data.price_type == PaymentPriceTypeEnum.CUSTOMER_CHOICE:
new_price_data = {
"currency": product_data.currency,
"product": product_id,
"custom_unit_amount": {
"enabled": True,
"minimum": int(product_data.amount * 100), # Convert to cents
},
}
else:
new_price_data = {
"currency": product_data.currency,
"unit_amount": int(product_data.amount * 100), # Convert to cents
"product": product_id,
}
if product_data.product_type == PaymentProductTypeEnum.SUBSCRIPTION:
new_price_data["recurring"] = {"interval": "month"}
new_price = stripe.Price.create(**new_price_data)
# Prepare the update data
update_data = {
"name": product_data.name,
"description": product_data.description or "",
"metadata": {"benefits": product_data.benefits},
"marketing_features": [
{"name": benefit.strip()}
for benefit in product_data.benefits.split(",")
if benefit.strip()
],
"default_price": new_price.id,
}
# Update the product in Stripe
updated_product = stripe.Product.modify(product_id, **update_data, stripe_account=stripe_acc_id)
# Archive all existing prices for the product
existing_prices = stripe.Price.list(product=product_id, active=True)
for price in existing_prices:
if price.id != new_price.id:
stripe.Price.modify(price.id, active=False, stripe_account=stripe_acc_id)
return updated_product
except stripe.StripeError as e:
raise HTTPException(
status_code=400, detail=f"Error updating Stripe product: {str(e)}"
)
async def create_checkout_session(
request: Request,
org_id: int,
product_id: int,
redirect_uri: str,
current_user: PublicUser | AnonymousUser,
db_session: Session,
):
# Get Stripe credentials
creds = await get_stripe_internal_credentials()
stripe.api_key = creds.get("stripe_secret_key")
stripe_acc_id = await get_stripe_connected_account_id(request, org_id, current_user, db_session)
# Get product details
statement = select(PaymentsProduct).where(
PaymentsProduct.id == product_id, PaymentsProduct.org_id == org_id
)
product = db_session.exec(statement).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
success_url = redirect_uri
cancel_url = redirect_uri
# Get the default price for the product
stripe_product = stripe.Product.retrieve(product.provider_product_id, stripe_account=stripe_acc_id)
line_items = [{"price": stripe_product.default_price, "quantity": 1}]
# Create or retrieve Stripe customer
try:
customers = stripe.Customer.list(
email=current_user.email, stripe_account=stripe_acc_id
)
if customers.data:
customer = customers.data[0]
else:
customer = stripe.Customer.create(
email=current_user.email,
metadata={
"user_id": str(current_user.id),
"org_id": str(org_id),
},
stripe_account=stripe_acc_id,
)
# Create initial payment user with pending status
payment_user = await create_payment_user(
request=request,
org_id=org_id,
user_id=current_user.id,
product_id=product_id,
status=PaymentStatusEnum.PENDING,
provider_data=customer,
current_user=InternalUser(),
db_session=db_session,
)
if not payment_user:
raise HTTPException(status_code=400, detail="Error creating payment user")
except stripe.StripeError as e:
# Clean up payment user if customer creation fails
if payment_user and payment_user.id:
await delete_payment_user(
request, org_id, payment_user.id, InternalUser(), db_session
)
raise HTTPException(
status_code=400, detail=f"Error creating/retrieving customer: {str(e)}"
)
# Create checkout session with customer
try:
checkout_session_params = {
"success_url": success_url,
"cancel_url": cancel_url,
"mode": (
"payment"
if product.product_type == PaymentProductTypeEnum.ONE_TIME
else "subscription"
),
"line_items": line_items,
"customer": customer.id,
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id),
}
}
# Add payment_intent_data only for one-time payments
if product.product_type == PaymentProductTypeEnum.ONE_TIME:
checkout_session_params["payment_intent_data"] = {
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id),
}
}
# Add subscription_data for subscription payments
else:
checkout_session_params["subscription_data"] = {
"metadata": {
"product_id": str(product.id),
"payment_user_id": str(payment_user.id),
}
}
checkout_session = stripe.checkout.Session.create(**checkout_session_params, stripe_account=stripe_acc_id)
return {"checkout_url": checkout_session.url, "session_id": checkout_session.id}
except stripe.StripeError as e:
# Clean up payment user if checkout session creation fails
if payment_user and payment_user.id:
await delete_payment_user(
request, org_id, payment_user.id, InternalUser(), db_session
)
logging.error(f"Error creating checkout session: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
async def generate_stripe_connect_link(
request: Request,
org_id: int,
redirect_uri: str,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
"""
Generate a Stripe OAuth link for connecting a Stripe account
"""
# Get credentials
creds = await get_stripe_internal_credentials()
stripe.api_key = creds.get("stripe_secret_key")
# Get learnhouse config for client_id
learnhouse_config = get_learnhouse_config()
client_id = learnhouse_config.payments_config.stripe.stripe_client_id
if not client_id:
raise HTTPException(status_code=400, detail="Stripe client ID not configured")
state = f"org_id={org_id}"
# Generate OAuth link for existing accounts
oauth_link = f"https://connect.stripe.com/oauth/authorize?response_type=code&client_id={client_id}&scope=read_write&redirect_uri={redirect_uri}&state={state}"
return {"connect_url": oauth_link}
async def create_stripe_account(
request: Request,
org_id: int,
type: Literal["standard"], # Only standard is supported for now, we'll see if we need express later
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
# Get credentials
creds = await get_stripe_internal_credentials()
stripe.api_key = creds.get("stripe_secret_key")
# Get existing payments config
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
existing_config = db_session.exec(statement).first()
if existing_config and existing_config.provider_specific_id:
logging.error(f"A Stripe Account is already linked to this organization: {existing_config.provider_specific_id}")
return existing_config.provider_specific_id
# Create Stripe account
stripe_account = stripe.Account.create(
type="standard",
capabilities={
"card_payments": {"requested": True},
"transfers": {"requested": True},
},
)
config_data = existing_config.model_dump() if existing_config else {}
config_data.update({
"enabled": True,
"provider_specific_id": stripe_account.id, # Use the ID directly
"provider_config": {"onboarding_completed": False}
})
# Update payments config for the org
await update_payments_config(
request,
org_id,
PaymentsConfigUpdate(**config_data),
current_user,
db_session,
)
return stripe_account
async def update_stripe_account_id(
request: Request,
org_id: int,
stripe_account_id: str,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
"""
Update the Stripe account ID for an organization
"""
# Get existing payments config
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
existing_config = db_session.exec(statement).first()
if not existing_config:
raise HTTPException(
status_code=404,
detail="No payments configuration found for this organization"
)
# Create config update with existing values but new stripe account id
config_data = existing_config.model_dump()
config_data["provider_specific_id"] = stripe_account_id
# Update payments config
await update_payments_config(
request,
org_id,
PaymentsConfigUpdate(**config_data),
current_user,
db_session,
)
return {"message": "Stripe account ID updated successfully"}
async def handle_stripe_oauth_callback(
request: Request,
org_id: int,
code: str,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
):
"""
Handle the OAuth callback from Stripe and complete the account connection
"""
creds = await get_stripe_internal_credentials()
stripe.api_key = creds.get("stripe_secret_key")
try:
# Exchange the authorization code for an access token
response = stripe.OAuth.token(
grant_type='authorization_code',
code=code,
)
connected_account_id = response.stripe_user_id
if not connected_account_id:
raise HTTPException(status_code=400, detail="No account ID received from Stripe")
# Now connected_account_id is guaranteed to be a string
await update_stripe_account_id(
request,
org_id,
connected_account_id,
current_user,
db_session,
)
return {"success": True, "account_id": connected_account_id}
except stripe.StripeError as e:
logging.error(f"Error connecting Stripe account: {str(e)}")
raise HTTPException(
status_code=400,
detail=f"Error connecting Stripe account: {str(e)}"
)

View file

@ -0,0 +1,250 @@
from fastapi import HTTPException, Request
from sqlmodel import Session, select
from typing import Any
from src.db.courses.courses import Course, CourseRead
from src.db.payments.payments_courses import PaymentsCourse
from src.db.payments.payments_users import PaymentsUser, PaymentStatusEnum, ProviderSpecificData
from src.db.payments.payments_products import PaymentsProduct
from src.db.resource_authors import ResourceAuthor
from src.db.users import InternalUser, PublicUser, AnonymousUser, User, UserRead
from src.db.organizations import Organization
from src.services.orgs.orgs import rbac_check
from datetime import datetime
async def create_payment_user(
request: Request,
org_id: int,
user_id: int,
product_id: int,
status: PaymentStatusEnum,
provider_data: Any,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
) -> PaymentsUser:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "create", db_session)
# Check if product exists
statement = select(PaymentsProduct).where(
PaymentsProduct.id == product_id,
PaymentsProduct.org_id == org_id
)
product = db_session.exec(statement).first()
if not product:
raise HTTPException(status_code=404, detail="Product not found")
provider_specific_data = ProviderSpecificData(
stripe_customer=provider_data if provider_data else None,
)
# Check if user already has a payment user for this product
statement = select(PaymentsUser).where(
PaymentsUser.user_id == user_id,
PaymentsUser.org_id == org_id,
PaymentsUser.payment_product_id == product_id
)
existing_payment_user = db_session.exec(statement).first()
if existing_payment_user:
# If status is PENDING, CANCELLED, or FAILED, delete the existing record
if existing_payment_user.status in [
PaymentStatusEnum.PENDING,
PaymentStatusEnum.CANCELLED,
PaymentStatusEnum.FAILED
]:
db_session.delete(existing_payment_user)
db_session.commit()
else:
raise HTTPException(status_code=400, detail="User already has purchase for this product")
# Create new payment user
payment_user = PaymentsUser(
user_id=user_id,
org_id=org_id,
payment_product_id=product_id,
provider_specific_data=provider_specific_data.model_dump(),
status=status
)
db_session.add(payment_user)
db_session.commit()
db_session.refresh(payment_user)
return payment_user
async def get_payment_user(
request: Request,
org_id: int,
payment_user_id: int,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
) -> PaymentsUser:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Get payment user
statement = select(PaymentsUser).where(
PaymentsUser.id == payment_user_id,
PaymentsUser.org_id == org_id
)
payment_user = db_session.exec(statement).first()
if not payment_user:
raise HTTPException(status_code=404, detail="Payment user not found")
return payment_user
async def update_payment_user_status(
request: Request,
org_id: int,
payment_user_id: int,
status: PaymentStatusEnum,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
) -> PaymentsUser:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "update", db_session)
# Get existing payment user
statement = select(PaymentsUser).where(
PaymentsUser.id == payment_user_id,
PaymentsUser.org_id == org_id
)
payment_user = db_session.exec(statement).first()
if not payment_user:
raise HTTPException(status_code=404, detail="Payment user not found")
# Update status
payment_user.status = status
payment_user.update_date = datetime.now()
db_session.add(payment_user)
db_session.commit()
db_session.refresh(payment_user)
return payment_user
async def list_payment_users(
request: Request,
org_id: int,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
) -> list[PaymentsUser]:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "read", db_session)
# Get all payment users for org ordered by id
statement = select(PaymentsUser).where(
PaymentsUser.org_id == org_id
).order_by(PaymentsUser.id.desc()) # type: ignore
payment_users = list(db_session.exec(statement).all()) # Convert to list
return payment_users
async def delete_payment_user(
request: Request,
org_id: int,
payment_user_id: int,
current_user: PublicUser | AnonymousUser | InternalUser,
db_session: Session,
) -> None:
# Check if organization exists
statement = select(Organization).where(Organization.id == org_id)
org = db_session.exec(statement).first()
if not org:
raise HTTPException(status_code=404, detail="Organization not found")
# RBAC check
await rbac_check(request, org.org_uuid, current_user, "delete", db_session)
# Get existing payment user
statement = select(PaymentsUser).where(
PaymentsUser.id == payment_user_id,
PaymentsUser.org_id == org_id
)
payment_user = db_session.exec(statement).first()
if not payment_user:
raise HTTPException(status_code=404, detail="Payment user not found")
# Delete payment user
db_session.delete(payment_user)
db_session.commit()
async def get_owned_courses(
request: Request,
current_user: PublicUser | AnonymousUser,
db_session: Session,
) -> list[CourseRead]:
# Anonymous users don't own any courses
if isinstance(current_user, AnonymousUser):
return []
# Get all active/completed payment users for the current user
statement = select(PaymentsUser).where(
PaymentsUser.user_id == current_user.id,
PaymentsUser.status.in_([PaymentStatusEnum.ACTIVE, PaymentStatusEnum.COMPLETED]) # type: ignore
)
payment_users = db_session.exec(statement).all()
# Get all product IDs from payment users
product_ids = [pu.payment_product_id for pu in payment_users]
# Get all courses linked to these products
courses = []
for product_id in product_ids:
# Get courses linked to this product through PaymentsCourse
statement = (
select(Course)
.join(PaymentsCourse, Course.id == PaymentsCourse.course_id) # type: ignore
.where(PaymentsCourse.payment_product_id == product_id)
)
product_courses = db_session.exec(statement).all()
courses.extend(product_courses)
# Remove duplicates by converting to set and back to list
unique_courses = list({course.id: course for course in courses}.values())
# Get authors for each course and convert to CourseRead
course_reads = []
for course in unique_courses:
# Get course authors
authors_statement = (
select(User)
.join(ResourceAuthor)
.where(ResourceAuthor.resource_uuid == course.course_uuid)
)
authors = db_session.exec(authors_statement).all()
# Convert authors to UserRead
author_reads = [UserRead.model_validate(author) for author in authors]
# Create CourseRead object
course_read = CourseRead(**course.model_dump(), authors=author_reads)
course_reads.append(course_read)
return course_reads

View file

@ -0,0 +1,59 @@
from fastapi import HTTPException
from sqlmodel import Session, select
import stripe
import logging
from src.db.payments.payments_products import PaymentsProduct
from src.db.users import User
from src.db.payments.payments import PaymentsConfig
logger = logging.getLogger(__name__)
async def get_user_from_customer(customer_id: str, db_session: Session) -> User:
"""Helper function to get user from Stripe customer ID"""
try:
customer = stripe.Customer.retrieve(customer_id)
statement = select(User).where(User.email == customer.email)
user = db_session.exec(statement).first()
if not user:
raise HTTPException(
status_code=404, detail=f"User not found for customer {customer_id}"
)
return user
except stripe.StripeError as e:
logger.error(f"Stripe error retrieving customer {customer_id}: {str(e)}")
raise HTTPException(
status_code=400, detail="Error retrieving customer information"
)
async def get_product_from_stripe_id(
product_id: str, db_session: Session
) -> PaymentsProduct:
"""Helper function to get product from Stripe product ID"""
statement = select(PaymentsProduct).where(
PaymentsProduct.provider_product_id == product_id
)
product = db_session.exec(statement).first()
if not product:
raise HTTPException(status_code=404, detail=f"Product not found: {product_id}")
return product
async def get_org_id_from_stripe_account(
stripe_account_id: str,
db_session: Session,
) -> int:
"""Get organization ID from Stripe account ID"""
statement = select(PaymentsConfig).where(
PaymentsConfig.provider_specific_id == stripe_account_id
)
config = db_session.exec(statement).first()
if not config:
raise HTTPException(
status_code=404,
detail=f"No organization found for Stripe account {stripe_account_id}",
)
return config.org_id

View file

@ -0,0 +1,179 @@
from typing import Literal
from fastapi import HTTPException, Request
from sqlmodel import Session, select
import stripe
import logging
from src.db.payments.payments_users import PaymentStatusEnum
from src.db.users import InternalUser
from src.services.payments.payments_users import update_payment_user_status
from src.services.payments.payments_stripe import get_stripe_internal_credentials
from src.db.payments.payments import PaymentsConfig, PaymentsConfigUpdate
from src.services.payments.payments_config import update_payments_config
from src.services.payments.utils.stripe_utils import get_org_id_from_stripe_account
logger = logging.getLogger(__name__)
async def handle_stripe_webhook(
request: Request,
webhook_type: Literal["connect", "standard"],
db_session: Session,
) -> dict:
# Get Stripe credentials
creds = await get_stripe_internal_credentials()
webhook_secret = creds.get(f'stripe_webhook_{webhook_type}_secret')
stripe.api_key = creds.get("stripe_secret_key")
if not webhook_secret:
logger.error("Stripe webhook secret not configured")
raise HTTPException(status_code=400, detail="Stripe webhook secret not configured")
# Get request data
payload = await request.body()
sig_header = request.headers.get('stripe-signature')
try:
# Verify webhook signature
event = stripe.Webhook.construct_event(payload, sig_header, webhook_secret)
except ValueError:
logger.error(ValueError)
raise HTTPException(status_code=400, detail="Invalid payload")
except stripe.SignatureVerificationError:
logger.error(stripe.SignatureVerificationError)
raise HTTPException(status_code=400, detail="Invalid signature")
try:
event_type = event.type
event_data = event.data.object
# Get organization ID based on the event type
stripe_account_id = event.account
if not stripe_account_id:
logger.error("Stripe account ID not found")
raise HTTPException(status_code=400, detail="Stripe account ID not found")
org_id = await get_org_id_from_stripe_account(stripe_account_id, db_session)
# Handle internal account events
if event_type == 'account.application.authorized':
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
config = db_session.exec(statement).first()
if not config:
logger.error("No payments configuration found for this organization")
raise HTTPException(
status_code=404,
detail="No payments configuration found for this organization"
)
config_data = config.model_dump()
config_data.update({
"enabled": True,
"active": True,
"provider_config": {
**config.provider_config,
"onboarding_completed": True
}
})
await update_payments_config(
request,
org_id,
PaymentsConfigUpdate(**config_data),
InternalUser(),
db_session,
)
logger.info(f"Account authorized for organization {org_id}")
return {"status": "success", "message": "Account authorized successfully"}
elif event_type == 'account.application.deauthorized':
statement = select(PaymentsConfig).where(PaymentsConfig.org_id == org_id)
config = db_session.exec(statement).first()
if not config:
raise HTTPException(
status_code=404,
detail="No payments configuration found for this organization"
)
config_data = config.model_dump()
config_data.update({
"enabled": True,
"active": False,
"provider_config": {
**config.provider_config,
"onboarding_completed": False
}
})
await update_payments_config(
request,
org_id,
PaymentsConfigUpdate(**config_data),
InternalUser(),
db_session,
)
logger.info(f"Account deauthorized for organization {org_id}")
return {"status": "success", "message": "Account deauthorized successfully"}
# Handle payment-related events
elif event_type == "checkout.session.completed":
session = event_data
payment_user_id = int(session.get("metadata", {}).get("payment_user_id"))
if session.get("mode") == "subscription":
if session.get("subscription"):
await update_payment_user_status(
request=request,
org_id=org_id,
payment_user_id=payment_user_id,
status=PaymentStatusEnum.ACTIVE,
current_user=InternalUser(),
db_session=db_session,
)
else:
if session.get("payment_status") == "paid":
await update_payment_user_status(
request=request,
org_id=org_id,
payment_user_id=payment_user_id,
status=PaymentStatusEnum.COMPLETED,
current_user=InternalUser(),
db_session=db_session,
)
elif event_type == "customer.subscription.deleted":
subscription = event_data
payment_user_id = int(subscription.get("metadata", {}).get("payment_user_id"))
await update_payment_user_status(
request=request,
org_id=org_id,
payment_user_id=payment_user_id,
status=PaymentStatusEnum.CANCELLED,
current_user=InternalUser(),
db_session=db_session,
)
elif event_type == "payment_intent.payment_failed":
payment_intent = event_data
payment_user_id = int(payment_intent.get("metadata", {}).get("payment_user_id"))
await update_payment_user_status(
request=request,
org_id=org_id,
payment_user_id=payment_user_id,
status=PaymentStatusEnum.FAILED,
current_user=InternalUser(),
db_session=db_session,
)
else:
logger.warning(f"Unhandled event type: {event_type}")
return {"status": "ignored", "message": f"Unhandled event type: {event_type}"}
return {"status": "success"}
except Exception as e:
logger.error(f"Error processing webhook: {str(e)}")
raise HTTPException(status_code=400, detail=f"Error processing webhook: {str(e)}")

View file

@ -2,7 +2,7 @@ from typing import Literal
from uuid import uuid4
from sqlmodel import Session, select
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
from src.db.users import AnonymousUser, PublicUser
@ -133,7 +133,7 @@ async def rbac_check(
):
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, role_uuid, db_session
)

View file

@ -9,7 +9,7 @@ from src.security.features_utils.usage import (
increase_feature_usage,
)
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
from src.db.usergroup_resources import UserGroupResource
@ -492,7 +492,7 @@ async def rbac_check(
):
await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request,
current_user.id,
action,

View file

@ -15,7 +15,7 @@ from src.services.orgs.invites import get_invite_code
from src.services.users.avatars import upload_avatar
from src.db.roles import Role, RoleRead
from src.security.rbac.rbac import (
authorization_verify_based_on_roles_and_authorship_and_usergroups,
authorization_verify_based_on_roles_and_authorship,
authorization_verify_if_user_is_anon,
)
from src.db.organizations import Organization, OrganizationRead
@ -491,7 +491,7 @@ async def authorize_user_action(
# RBAC check
authorized = (
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, resource_uuid, db_session
)
)
@ -564,7 +564,7 @@ async def rbac_check(
if current_user.id == 0: # if user is anonymous
return True
else:
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, "create", "user_x", db_session
)
@ -575,7 +575,7 @@ async def rbac_check(
if current_user.user_uuid == user_uuid:
return True
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
await authorization_verify_based_on_roles_and_authorship(
request, current_user.id, action, user_uuid, db_session
)

View file

@ -8,7 +8,7 @@ from src.services.install.install import (
install_default_elements,
)
# TODO: Depreceated and need to be removed and remade
async def create_initial_data_for_tests(db_session: Session):
# Install default elements
await install_default_elements({}, db_session)

View file

@ -0,0 +1,39 @@
export const dynamic = 'force-dynamic' // defaults to auto
export const revalidate = 0
import { NextResponse } from 'next/server';
import { checkHealth } from '@services/utils/health';
export async function GET() {
const health = await checkHealth()
if (health.success === true) {
return NextResponse.json(
{
status: 'healthy',
timestamp: new Date().toISOString(),
health: health.data,
},
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
}
)
} else {
return NextResponse.json(
{
status: 'unhealthy',
timestamp: new Date().toISOString(),
health: null,
error: health.HTTPmessage,
},
{
status: 503,
headers: {
'Content-Type': 'application/json',
},
}
);
}
}

View file

@ -6,7 +6,7 @@ import FormLayout, {
FormField,
FormLabelAndMessage,
Input,
} from '@components/StyledElements/Form/Form'
} from '@components/Objects/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'
import { getOrgLogoMediaDirectory } from '@services/media/media'
import { AlertTriangle, Info } from 'lucide-react'

View file

@ -1,6 +1,6 @@
'use client'
import { OrgProvider } from '@components/Contexts/OrgContext'
import ErrorUI from '@components/StyledElements/Error/Error'
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
import { useSearchParams } from 'next/navigation'

View file

@ -4,7 +4,7 @@ import FormLayout, {
FormField,
FormLabelAndMessage,
Input,
} from '@components/StyledElements/Form/Form'
} from '@components/Objects/StyledElements/Form/Form'
import Image from 'next/image'
import * as Form from '@radix-ui/react-form'
import { useFormik } from 'formik'

View file

@ -6,7 +6,7 @@ import FormLayout, {
FormField,
FormLabelAndMessage,
Input,
} from '@components/StyledElements/Form/Form'
} from '@components/Objects/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'
import { getOrgLogoMediaDirectory } from '@services/media/media'
import { AlertTriangle, Info } from 'lucide-react'

View file

@ -7,7 +7,7 @@ import FormLayout, {
FormLabelAndMessage,
Input,
Textarea,
} from '@components/StyledElements/Form/Form'
} from '@components/Objects/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'
import { AlertTriangle, Check, User } from 'lucide-react'
import Link from 'next/link'
@ -110,8 +110,10 @@ function InviteOnlySignUpComponent(props: InviteOnlySignUpProps) {
<div className="font-bold text-sm">{message}</div>
</div>
<hr className="border-green-900/20 800 w-40 border" />
<Link className="flex space-x-2 items-center" href={'/login'}>
<User size={14} /> <div>Login </div>
<Link className="flex space-x-2 items-center" href={
`/login?orgslug=${org?.slug}`
} >
<User size={14} /> <div>Login to your account</div>
</Link>
</div>
)}

View file

@ -7,7 +7,7 @@ import FormLayout, {
FormLabelAndMessage,
Input,
Textarea,
} from '@components/StyledElements/Form/Form'
} from '@components/Objects/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'
import { AlertTriangle, Check, User } from 'lucide-react'
import Link from 'next/link'

View file

@ -14,7 +14,7 @@ import InviteOnlySignUpComponent from './InviteOnlySignUp'
import { useRouter, useSearchParams } from 'next/navigation'
import { validateInviteCode } from '@services/organizations/invites'
import PageLoading from '@components/Objects/Loaders/PageLoading'
import Toast from '@components/StyledElements/Toast/Toast'
import Toast from '@components/Objects/StyledElements/Toast/Toast'
import toast from 'react-hot-toast'
import { BarLoader } from 'react-spinners'
import { joinOrg } from '@services/organizations/orgs'

View file

@ -1,7 +1,7 @@
'use client'
import React, { useEffect } from 'react'
import { INSTALL_STEPS } from './steps/steps'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { useRouter, useSearchParams } from 'next/navigation'
import { Suspense } from 'react'

View file

@ -4,7 +4,7 @@ import FormLayout, {
FormField,
FormLabelAndMessage,
Input,
} from '@components/StyledElements/Form/Form'
} from '@components/Objects/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'
import { getAPIUrl } from '@services/config/config'
import { createNewUserInstall, updateInstall } from '@services/install/install'

View file

@ -3,7 +3,7 @@ import FormLayout, {
FormField,
FormLabelAndMessage,
Input,
} from '@components/StyledElements/Form/Form'
} from '@components/Objects/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form'
import { useFormik } from 'formik'
import { BarLoader } from 'react-spinners'

View file

@ -1,6 +1,6 @@
'use client' // Error components must be Client Components
import ErrorUI from '@components/StyledElements/Error/Error'
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
import { useEffect } from 'react'
export default function Error({

View file

@ -1,4 +1,4 @@
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { getUriWithOrg } from '@services/config/config'
import { getCollectionById } from '@services/courses/collections'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'

View file

@ -1,17 +1,17 @@
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { getUriWithOrg } from '@services/config/config'
import { getOrganizationContextInfo } from '@services/organizations/orgs'
import { Metadata } from 'next'
import Link from 'next/link'
import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail'
import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton'
import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder'
import NewCollectionButton from '@components/Objects/StyledElements/Buttons/NewCollectionButton'
import { nextAuthOptions } from 'app/auth/options'
import { getServerSession } from 'next-auth'
import { getOrgCollections } from '@services/courses/collections'
import { getOrgThumbnailMediaDirectory } from '@services/media/media'
import ContentPlaceHolderIfUserIsNotAdmin from '@components/Objects/ContentPlaceHolder'
type MetadataProps = {
params: { orgslug: string; courseid: string }

View file

@ -7,7 +7,7 @@ import { BookOpenCheck, Check, CheckCircle, MoreVertical, UserRoundPen } from 'l
import { markActivityAsComplete } from '@services/courses/activity'
import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf'
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { usePathname, useRouter } from 'next/navigation'
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
@ -24,8 +24,9 @@ import { AssignmentsTaskProvider } from '@components/Contexts/Assignments/Assign
import AssignmentSubmissionProvider, { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext'
import toast from 'react-hot-toast'
import { mutate } from 'swr'
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { useMediaQuery } from 'usehooks-ts'
import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer'
interface ActivityClientProps {
activityid: string
@ -129,7 +130,7 @@ function ActivityClient(props: ActivityClientProps) {
</h1>
</div>
<div className="flex space-x-1 items-center">
{activity && activity.published == true && (
{activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' &&
<>
@ -173,14 +174,16 @@ function ActivityClient(props: ActivityClientProps) {
)}
{activity && activity.published == true && (
<div
className={`p-7 drop-shadow-sm rounded-lg ${bgColor}`}
>
<>
{activity.content.paid_access == false ? (
<PaidCourseActivityDisclaimer course={course} />
) : (
<div className={`p-7 drop-shadow-sm rounded-lg ${bgColor}`}>
{/* Activity Types */}
<div>
{activity.activity_type == 'TYPE_DYNAMIC' && (
<Canva content={activity.content} activity={activity} />
)}
{/* todo : use apis & streams instead of this */}
{activity.activity_type == 'TYPE_VIDEO' && (
<VideoActivity course={course} activity={activity} />
)}
@ -208,6 +211,8 @@ function ActivityClient(props: ActivityClientProps) {
</div>
</div>
)}
</>
)}
{<div style={{ height: '100px' }}></div>}
</div>
</GeneralWrapperStyled>

View file

@ -1,6 +1,6 @@
'use client' // Error components must be Client Components
import ErrorUI from '@components/StyledElements/Error/Error'
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
import { useEffect } from 'react'
export default function Error({

View file

@ -1,5 +1,4 @@
'use client'
import { removeCourse, startCourse } from '@services/courses/activity'
import Link from 'next/link'
import React, { useEffect, useState } from 'react'
import { getUriWithOrg } from '@services/config/config'
@ -7,7 +6,7 @@ import PageLoading from '@components/Objects/Loaders/PageLoading'
import { revalidateTags } from '@services/utils/ts/requests'
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
import { useRouter } from 'next/navigation'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import {
getCourseThumbnailMediaDirectory,
getUserAvatarMediaDirectory,
@ -15,15 +14,13 @@ import {
import { ArrowRight, Backpack, Check, File, Sparkles, Video } from 'lucide-react'
import { useOrg } from '@components/Contexts/OrgContext'
import UserAvatar from '@components/Objects/UserAvatar'
import CourseUpdates from '@components/Objects/CourseUpdates/CourseUpdates'
import CourseUpdates from '@components/Objects/Courses/CourseUpdates/CourseUpdates'
import { CourseProvider } from '@components/Contexts/CourseContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useMediaQuery } from 'usehooks-ts'
import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions'
const CourseClient = (props: any) => {
const [user, setUser] = useState<any>({})
const [learnings, setLearnings] = useState<any>([])
const session = useLHSession() as any;
const courseuuid = props.courseuuid
const orgslug = props.orgslug
const course = props.course
@ -37,33 +34,6 @@ const CourseClient = (props: any) => {
setLearnings(learnings)
}
async function startCourseUI() {
// Create activity
await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
await revalidateTags(['courses'], orgslug)
router.refresh()
// refresh page (FIX for Next.js BUG)
// window.location.reload();
}
function isCourseStarted() {
const runs = course.trail?.runs
if (!runs) return false
return runs.some(
(run: any) =>
run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id
)
}
async function quitCourse() {
// Close activity
let activity = await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
// Mutate course
await revalidateTags(['courses'], orgslug)
router.refresh()
}
useEffect(() => {
getLearningTags()
}, [org, course])
@ -113,11 +83,11 @@ const CourseClient = (props: any) => {
course={course}
/>
<div className="flex flex-col md:flex-row pt-10">
<div className="course_metadata_left grow space-y-2">
<h2 className="py-3 text-2xl font-bold">Description</h2>
<div className="flex flex-col md:flex-row md:space-x-10 space-y-6 md:space-y-0 pt-10">
<div className="course_metadata_left w-full md:basis-3/4 space-y-2">
<h2 className="py-3 text-2xl font-bold">About</h2>
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
<p className="py-5 px-5">{course.description}</p>
<p className="py-5 px-5 whitespace-pre-wrap">{course.about}</p>
</div>
{learnings.length > 0 && learnings[0] !== 'null' && (
@ -305,60 +275,8 @@ const CourseClient = (props: any) => {
})}
</div>
</div>
<div className="course_metadata_right space-y-3 w-full md:w-72 antialiased flex flex-col md:ml-10 h-fit p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden mt-6 md:mt-0">
{user && (
<div className="flex flex-row md:flex-col mx-auto space-y-0 md:space-y-3 space-x-4 md:space-x-0 px-2 py-2 items-center">
<UserAvatar
border="border-8"
avatar_url={course.authors[0].avatar_image ? getUserAvatarMediaDirectory(course.authors[0].user_uuid, course.authors[0].avatar_image) : ''}
predefined_avatar={course.authors[0].avatar_image ? undefined : 'empty'}
width={isMobile ? 60 : 100}
/>
<div className="md:-space-y-2">
<div className="text-[12px] text-neutral-400 font-semibold">
Author
</div>
<div className="text-lg md:text-xl font-bold text-neutral-800">
{course.authors[0].first_name &&
course.authors[0].last_name && (
<div className="flex space-x-2 items-center">
<p>
{course.authors[0].first_name +
' ' +
course.authors[0].last_name}
</p>
<span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold">
{' '}
@{course.authors[0].username}
</span>
</div>
)}
{!course.authors[0].first_name &&
!course.authors[0].last_name && (
<div className="flex space-x-2 items-center">
<p>@{course.authors[0].username}</p>
</div>
)}
</div>
</div>
</div>
)}
{isCourseStarted() ? (
<button
className="py-2 px-5 mx-auto rounded-xl text-white font-bold h-12 w-full md:w-[200px] drop-shadow-md bg-red-600 hover:bg-red-700 hover:cursor-pointer"
onClick={quitCourse}
>
Quit Course
</button>
) : (
<button
className="py-2 px-5 mx-auto rounded-xl text-white font-bold h-12 w-full md:w-[200px] drop-shadow-md bg-black hover:bg-gray-900 hover:cursor-pointer"
onClick={startCourseUI}
>
Start Course
</button>
)}
<div className='course_metadata_right basis-1/4'>
<CoursesActions courseuuid={courseuuid} orgslug={orgslug} course={course} />
</div>
</div>
</GeneralWrapperStyled>

View file

@ -1,13 +1,13 @@
'use client'
import CreateCourseModal from '@components/Objects/Modals/Course/Create/CreateCourse'
import Modal from '@components/StyledElements/Modal/Modal'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
import React from 'react'
import { useSearchParams } from 'next/navigation'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton'
import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton'
import useAdminStatus from '@components/Hooks/useAdminStatus'
interface CourseProps {

View file

@ -1,6 +1,6 @@
'use client' // Error components must be Client Components
import ErrorUI from '@components/StyledElements/Error/Error'
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
import { useEffect } from 'react'
export default function Error({

View file

@ -1,6 +1,6 @@
'use client' // Error components must be Client Components
import ErrorUI from '@components/StyledElements/Error/Error'
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
import { useEffect } from 'react'
export default function Error({

View file

@ -1,8 +1,8 @@
'use client'
import '@styles/globals.css'
import { Menu } from '@components/Objects/Menu/Menu'
import { SessionProvider } from 'next-auth/react'
import Watermark from '@components/Watermark'
import Watermark from '@components/Objects/Watermark'
import { OrgMenu } from '@components/Objects/Menus/OrgMenu'
export default function RootLayout({
children,
@ -14,7 +14,7 @@ export default function RootLayout({
return (
<>
<SessionProvider>
<Menu orgslug={params?.orgslug}></Menu>
<OrgMenu orgslug={params?.orgslug}></OrgMenu>
{children}
<Watermark />
</SessionProvider>

View file

@ -4,14 +4,14 @@ import { getUriWithOrg } from '@services/config/config'
import { getOrgCourses } from '@services/courses/courses'
import Link from 'next/link'
import { getOrganizationContextInfo } from '@services/organizations/orgs'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'
import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail'
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton'
import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton'
import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder'
import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton'
import NewCollectionButton from '@components/Objects/StyledElements/Buttons/NewCollectionButton'
import ContentPlaceHolderIfUserIsNotAdmin from '@components/Objects/ContentPlaceHolder'
import { getOrgCollections } from '@services/courses/collections'
import { getServerSession } from 'next-auth'
import { nextAuthOptions } from 'app/auth/options'

View file

@ -3,8 +3,8 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import PageLoading from '@components/Objects/Loaders/PageLoading'
import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement'
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { getAPIUrl } from '@services/config/config'
import { swrFetcher } from '@services/utils/ts/requests'
import React, { useEffect } from 'react'

View file

@ -1,9 +1,9 @@
'use client';
import DashLeftMenu from '@components/Dashboard/UI/DashLeftMenu'
import DashMobileMenu from '@components/Dashboard/UI/DashMobileMenu'
import DashLeftMenu from '@components/Dashboard/Menus/DashLeftMenu';
import DashMobileMenu from '@components/Dashboard/Menus/DashMobileMenu';
import AdminAuthorization from '@components/Security/AdminAuthorization'
import { SessionProvider } from 'next-auth/react'
import React, { useState, useEffect } from 'react'
import React from 'react'
import { useMediaQuery } from 'usehooks-ts';
function ClientAdminLayout({

View file

@ -3,7 +3,7 @@ import { useAssignments } from '@components/Contexts/Assignments/AssignmentConte
import { useAssignmentsTask, useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import { useOrg } from '@components/Contexts/OrgContext';
import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/StyledElements/Form/Form';
import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/Objects/StyledElements/Form/Form';
import * as Form from '@radix-ui/react-form';
import { getActivityByID } from '@services/courses/activities';
import { updateAssignmentTask, updateReferenceFile } from '@services/courses/assignments';

View file

@ -1,5 +1,5 @@
import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext'
import Modal from '@components/StyledElements/Modal/Modal';
import Modal from '@components/Objects/StyledElements/Modal/Modal';
import { FileUp, ListTodo, PanelLeftOpen, Plus } from 'lucide-react';
import React, { useEffect } from 'react'
import NewTaskModal from './Modals/NewTaskModal';

View file

@ -1,9 +1,9 @@
'use client';
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { BookOpen, BookX, EllipsisVertical, Eye, Layers2, Monitor, UserRoundPen } from 'lucide-react'
import React, { useEffect } from 'react'
import { AssignmentProvider, useAssignments } from '@components/Contexts/Assignments/AssignmentContext';
import ToolTip from '@components/StyledElements/Tooltip/Tooltip';
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip';
import { updateAssignment } from '@services/courses/assignments';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import { mutate } from 'swr';

View file

@ -1,6 +1,6 @@
import { useLHSession } from '@components/Contexts/LHSessionContext';
import UserAvatar from '@components/Objects/UserAvatar';
import Modal from '@components/StyledElements/Modal/Modal';
import Modal from '@components/Objects/StyledElements/Modal/Modal';
import { getAPIUrl } from '@services/config/config';
import { getUserAvatarMediaDirectory } from '@services/media/media';
import { swrFetcher } from '@services/utils/ts/requests';

View file

@ -1,7 +1,7 @@
'use client';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import { useOrg } from '@components/Contexts/OrgContext';
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { getAPIUrl, getUriWithOrg } from '@services/config/config';
import { getAssignmentsFromACourse } from '@services/courses/assignments';
import { getCourseThumbnailMediaDirectory } from '@services/media/media';

View file

@ -1,10 +1,10 @@
'use client'
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import CreateCourseModal from '@components/Objects/Modals/Course/Create/CreateCourse'
import CourseThumbnail, { removeCoursePrefix } from '@components/Objects/Thumbnails/CourseThumbnail'
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton'
import Modal from '@components/StyledElements/Modal/Modal'
import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
import { useSearchParams } from 'next/navigation'
import React from 'react'
import useAdminStatus from '@components/Hooks/useAdminStatus'

View file

@ -1,14 +1,14 @@
'use client'
import EditCourseStructure from '../../../../../../../../components/Dashboard/Course/EditCourseStructure/EditCourseStructure'
import { getUriWithOrg } from '@services/config/config'
import React from 'react'
import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext'
import Link from 'next/link'
import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop'
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
import { motion } from 'framer-motion'
import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral'
import { GalleryVerticalEnd, Info, UserRoundCog } from 'lucide-react'
import EditCourseAccess from '@components/Dashboard/Course/EditCourseAccess/EditCourseAccess'
import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure'
import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
export type CourseOverviewParams = {
orgslug: string

View file

@ -1,11 +1,11 @@
'use client'
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { getUriWithOrg } from '@services/config/config'
import { Info } from 'lucide-react'
import Link from 'next/link'
import React, { useEffect } from 'react'
import { motion } from 'framer-motion'
import OrgEditGeneral from '@components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral'
import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral'
export type OrgParams = {
subpage: string

View file

@ -0,0 +1,142 @@
'use client'
import React, { useState, useEffect } from 'react'
import { motion } from 'framer-motion'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config'
import { CreditCard, Settings, Repeat, BookOpen, Users, DollarSign, Gem } from 'lucide-react'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import PaymentsConfigurationPage from '@components/Dashboard/Pages/Payments/PaymentsConfigurationPage'
import PaymentsProductPage from '@components/Dashboard/Pages/Payments/PaymentsProductPage'
import PaymentsCustomersPage from '@components/Dashboard/Pages/Payments/PaymentsCustomersPage'
import useFeatureFlag from '@components/Hooks/useFeatureFlag'
export type PaymentsParams = {
subpage: string
orgslug: string
}
function PaymentsPage({ params }: { params: PaymentsParams }) {
const session = useLHSession() as any
const org = useOrg() as any
const [selectedSubPage, setSelectedSubPage] = useState(params.subpage || 'general')
const [H1Label, setH1Label] = useState('')
const [H2Label, setH2Label] = useState('')
const isPaymentsEnabled = useFeatureFlag({
path: ['features', 'payments', 'enabled'],
defaultValue: false
})
useEffect(() => {
handleLabels()
}, [selectedSubPage])
if (!isPaymentsEnabled) {
return (
<div className="h-screen w-full bg-[#f8f8f8] flex items-center justify-center p-4">
<div className="bg-white p-6 rounded-lg shadow-md text-center max-w-md">
<h2 className="text-xl font-bold mb-4">Payments Not Available</h2>
<p className="text-gray-600">The payments feature is not enabled for this organization.</p>
<p className="text-gray-600 mt-2">Please contact your administrator to enable payments.</p>
</div>
</div>
)
}
function handleLabels() {
if (selectedSubPage === 'general') {
setH1Label('Payments')
setH2Label('Overview of your payment settings and transactions')
}
if (selectedSubPage === 'configuration') {
setH1Label('Payment Configuration')
setH2Label('Set up and manage your payment gateway')
}
if (selectedSubPage === 'subscriptions') {
setH1Label('Subscriptions')
setH2Label('Manage your subscription plans')
}
if (selectedSubPage === 'paid-products') {
setH1Label('Paid Products')
setH2Label('Manage your paid products and pricing')
}
if (selectedSubPage === 'customers') {
setH1Label('Customers')
setH2Label('View and manage your customer information')
}
}
return (
<div className="h-screen w-full bg-[#f8f8f8] flex flex-col">
<div className="pl-10 pr-10 tracking-tight bg-[#fcfbfc] z-10 shadow-[0px_4px_16px_rgba(0,0,0,0.06)]">
<BreadCrumbs type="payments" />
<div className="my-2 py-3">
<div className="w-100 flex flex-col space-y-1">
<div className="pt-3 flex font-bold text-4xl tracking-tighter">
{H1Label}
</div>
<div className="flex font-medium text-gray-400 text-md">
{H2Label}{' '}
</div>
</div>
</div>
<div className="flex space-x-5 font-black text-sm">
<TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/customers')}
icon={<Users size={16} />}
label="Customers"
isActive={selectedSubPage === 'customers'}
onClick={() => setSelectedSubPage('customers')}
/>
<TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/paid-products')}
icon={<Gem size={16} />}
label="Products & Subscriptions"
isActive={selectedSubPage === 'paid-products'}
onClick={() => setSelectedSubPage('paid-products')}
/>
<TabLink
href={getUriWithOrg(params.orgslug, '/dash/payments/configuration')}
icon={<Settings size={16} />}
label="Configuration"
isActive={selectedSubPage === 'configuration'}
onClick={() => setSelectedSubPage('configuration')}
/>
</div>
</div>
<div className="h-6"></div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1, type: 'spring', stiffness: 80 }}
className="flex-1 overflow-y-auto"
>
{selectedSubPage === 'general' && <div>General</div>}
{selectedSubPage === 'configuration' && <PaymentsConfigurationPage />}
{selectedSubPage === 'paid-products' && <PaymentsProductPage />}
{selectedSubPage === 'customers' && <PaymentsCustomersPage />}
</motion.div>
</div>
)
}
const TabLink = ({ href, icon, label, isActive, onClick }: { href: string, icon: React.ReactNode, label: string, isActive: boolean, onClick: () => void }) => (
<Link href={href}>
<div
onClick={onClick}
className={`py-2 w-fit text-center border-black transition-all ease-linear ${isActive ? 'border-b-4' : 'opacity-50'
} cursor-pointer`}
>
<div className="flex items-center space-x-2.5 mx-2">
{icon}
<div>{label}</div>
</div>
</div>
</Link>
)
export default PaymentsPage

View file

@ -0,0 +1,64 @@
'use client'
import React from 'react'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import useSWR from 'swr'
import { getOwnedCourses } from '@services/payments/payments'
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'
import PageLoading from '@components/Objects/Loaders/PageLoading'
import { BookOpen, Package2 } from 'lucide-react'
function OwnedCoursesPage() {
const org = useOrg() as any
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const { data: ownedCourses, error, isLoading } = useSWR(
org ? [`/payments/${org.id}/courses/owned`, access_token] : null,
([url, token]) => getOwnedCourses(org.id, token)
)
if (isLoading) return <PageLoading />
if (error) return <div>Error loading owned courses</div>
return (
<div className="h-full w-full bg-[#f8f8f8] pl-10 pr-10 pt-5 ">
<div className="flex flex-col bg-white nice-shadow px-5 py-3 rounded-md mb-6">
<div className="flex items-center gap-4">
<Package2 className="w-8 h-8 text-gray-800" />
<div className="flex flex-col -space-y-1">
<h1 className="font-bold text-xl text-gray-800">My Courses</h1>
<h2 className="text-gray-500 text-md">Courses you have purchased or subscribed to</h2>
</div>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4 w-full">
{ownedCourses?.map((course: any) => (
<div key={course.course_uuid} className="p-3">
<CourseThumbnail course={course} orgslug={org.slug} />
</div>
))}
{(!ownedCourses || ownedCourses.length === 0) && (
<div className="col-span-full flex justify-center items-center py-8">
<div className="text-center">
<div className="mb-4">
<BookOpen className="w-12 h-12 mx-auto text-gray-400" />
</div>
<h2 className="text-xl font-bold text-gray-600 mb-2">
No purchased courses
</h2>
<p className="text-md text-gray-400">
You haven't purchased any courses yet
</p>
</div>
</div>
)}
</div>
</div>
)
}
export default OwnedCoursesPage

View file

@ -1,12 +1,12 @@
'use client'
import React, { useEffect } from 'react'
import { motion } from 'framer-motion'
import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral'
import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword'
import UserEditGeneral from '@components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral'
import UserEditPassword from '@components/Dashboard/Pages/UserAccount/UserEditPassword/UserEditPassword'
import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config'
import { Info, Lock } from 'lucide-react'
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { useLHSession } from '@components/Contexts/LHSessionContext'
export type SettingsParams = {

View file

@ -5,13 +5,13 @@ import Link from 'next/link'
import { useMediaQuery } from 'usehooks-ts'
import { getUriWithOrg } from '@services/config/config'
import { Monitor, ScanEye, SquareUserRound, UserPlus, Users } from 'lucide-react'
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers'
import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess'
import OrgUsersAdd from '@components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd'
import OrgUserGroups from '@components/Dashboard/Users/OrgUserGroups/OrgUserGroups'
import OrgUsers from '@components/Dashboard/Pages/Users/OrgUsers/OrgUsers'
import OrgAccess from '@components/Dashboard/Pages/Users/OrgAccess/OrgAccess'
import OrgUsersAdd from '@components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd'
import OrgUserGroups from '@components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups'
export type SettingsParams = {
subpage: string

View file

@ -1,9 +0,0 @@
import React from 'react'
function HealthPage() {
return (
<div>OK</div>
)
}
export default HealthPage

View file

@ -1,9 +1,9 @@
'use client'
import { OrgProvider } from '@components/Contexts/OrgContext'
import NextTopLoader from 'nextjs-toploader';
import Toast from '@components/StyledElements/Toast/Toast'
import Toast from '@components/Objects/StyledElements/Toast/Toast'
import '@styles/globals.css'
import Onboarding from '@components/Onboarding/Onboarding';
import Onboarding from '@components/Objects/Onboarding/Onboarding';
export default function RootLayout({
children,

View file

@ -0,0 +1,124 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext'
import { getUriWithOrg } from '@services/config/config'
import { Check, Loader2, AlertTriangle } from 'lucide-react'
import { motion } from 'framer-motion'
import toast from 'react-hot-toast'
import { verifyStripeConnection } from '@services/payments/payments'
import Image from 'next/image'
import learnhouseIcon from 'public/learnhouse_bigicon_1.png'
function StripeConnectCallback() {
const router = useRouter()
const searchParams = useSearchParams()
const session = useLHSession() as any
const [status, setStatus] = useState<'processing' | 'success' | 'error'>('processing')
const [message, setMessage] = useState('')
useEffect(() => {
const verifyConnection = async () => {
try {
const code = searchParams.get('code')
const state = searchParams.get('state')
const orgId = state?.split('=')[1] // Extract org_id value after '='
if (!code || !orgId) {
throw new Error('Missing required parameters')
}
const response = await verifyStripeConnection(
parseInt(orgId),
code,
session?.data?.tokens?.access_token
)
// Wait for 1 second to show processing state
await new Promise(resolve => setTimeout(resolve, 1000))
setStatus('success')
setMessage('Successfully connected to Stripe!')
// Close the window after 2 seconds of showing success
setTimeout(() => {
window.close()
}, 2000)
} catch (error) {
console.error('Error verifying Stripe connection:', error)
setStatus('error')
setMessage('Failed to complete Stripe connection')
toast.error('Failed to connect to Stripe')
}
}
if (session) {
verifyConnection()
}
}, [session, router, searchParams])
return (
<div className="h-screen w-full bg-[#f8f8f8] flex items-center justify-center">
<div className="flex flex-col items-center">
<div className="mb-10">
<Image
quality={100}
width={50}
height={50}
src={learnhouseIcon}
alt=""
/>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="bg-white p-8 rounded-xl nice-shadow max-w-md w-full mx-4"
>
<div className="flex flex-col items-center text-center space-y-4">
{status === 'processing' && (
<>
<Loader2 className="h-12 w-12 text-blue-500 animate-spin" />
<h2 className="text-xl font-semibold text-gray-800">
Completing Stripe Connection
</h2>
<p className="text-gray-500">
Please wait while we finish setting up your Stripe integration...
</p>
</>
)}
{status === 'success' && (
<>
<div className="bg-green-100 p-3 rounded-full">
<Check className="h-8 w-8 text-green-600" />
</div>
<h2 className="text-xl font-semibold text-gray-800">{message}</h2>
<p className="text-gray-500">
You can now return to the dashboard to start using payments.
</p>
</>
)}
{status === 'error' && (
<>
<div className="bg-red-100 p-3 rounded-full">
<AlertTriangle className="h-8 w-8 text-red-600" />
</div>
<h2 className="text-xl font-semibold text-gray-800">{message}</h2>
<p className="text-gray-500">
Please try again or contact support if the problem persists.
</p>
</>
)}
</div>
</motion.div>
</div>
</div>
)
}
export default StripeConnectCallback

View file

@ -1,17 +1,13 @@
'use client'
import PageLoading from '@components/Objects/Loaders/PageLoading';
import { useSession } from 'next-auth/react';
import React, { useContext, createContext, useEffect } from 'react'
import React, { useContext, createContext } from 'react'
export const SessionContext = createContext({}) as any
function LHSessionProvider({ children }: { children: React.ReactNode }) {
const session = useSession();
useEffect(() => {
}, [])
if (session && session.status == 'loading') {
return <PageLoading />
}

View file

@ -4,8 +4,8 @@ import { swrFetcher } from '@services/utils/ts/requests'
import React, { createContext, useContext, useMemo } from 'react'
import useSWR from 'swr'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import ErrorUI from '@components/StyledElements/Error/Error'
import InfoUI from '@components/StyledElements/Info/Info'
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
import InfoUI from '@components/Objects/StyledElements/Info/Info'
import { usePathname } from 'next/navigation'
export const OrgContext = createContext(null)

View file

@ -1,9 +1,9 @@
'use client'
import { useOrg } from '@components/Contexts/OrgContext'
import { signOut } from 'next-auth/react'
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
import LearnHouseDashboardLogo from '@public/dashLogo.png'
import { Backpack, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, Package2, School, Settings, Users, Vault } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import React, { useEffect } from 'react'
@ -11,11 +11,13 @@ import UserAvatar from '../../Objects/UserAvatar'
import AdminAuthorization from '@components/Security/AdminAuthorization'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import useFeatureFlag from '@components/Hooks/useFeatureFlag'
function DashLeftMenu() {
const org = useOrg() as any
const session = useLHSession() as any
const [loading, setLoading] = React.useState(true)
const isPaymentsEnabled = useFeatureFlag({ path: ['features', 'payments', 'enabled'], defaultValue: false })
function waitForEverythingToLoad() {
if (org && session) {
@ -112,6 +114,16 @@ function DashLeftMenu() {
<Users size={18} />
</Link>
</ToolTip>
{isPaymentsEnabled && (
<ToolTip content={'Payments'} slateBlack sideOffset={8} side="right">
<Link
className="bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear"
href={`/dash/payments/customers`}
>
<BadgeDollarSign size={18} />
</Link>
</ToolTip>
)}
<ToolTip
content={'Organization'}
slateBlack
@ -139,7 +151,24 @@ function DashLeftMenu() {
<UserAvatar border="border-4" width={35} />
</div>
</ToolTip>
<div className="flex items-center flex-col space-y-1">
<div className="flex items-center flex-col space-y-3">
<div className="flex flex-col space-y-1 py-1">
<ToolTip
content={session.data.user.username + "'s Owned Courses"}
slateBlack
sideOffset={8}
side="right"
>
<Link
href={'/dash/user-account/owned'}
className="py-1"
>
<Package2
className="mx-auto text-neutral-400 cursor-pointer"
size={18}
/>
</Link>
</ToolTip>
<ToolTip
content={session.data.user.username + "'s Settings"}
slateBlack
@ -148,7 +177,7 @@ function DashLeftMenu() {
>
<Link
href={'/dash/user-account/settings/general'}
className="py-3"
className="py-1"
>
<Settings
className="mx-auto text-neutral-400 cursor-pointer"
@ -156,6 +185,7 @@ function DashLeftMenu() {
/>
</Link>
</ToolTip>
</div>
<ToolTip
content={'Logout'}
slateBlack

View file

@ -1,13 +1,13 @@
'use client'
import { useOrg } from '@components/Contexts/OrgContext'
import { signOut } from 'next-auth/react'
import { Backpack, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
import Link from 'next/link'
import React from 'react'
import AdminAuthorization from '@components/Security/AdminAuthorization'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
function DashMobileMenu() {
const org = useOrg() as any
@ -42,6 +42,12 @@ function DashMobileMenu() {
<span className="text-xs mt-1">Assignments</span>
</Link>
</ToolTip>
<ToolTip content={'Payments'} slateBlack sideOffset={8} side="top">
<Link href={`/dash/payments/customers`} className="flex flex-col items-center p-2">
<BadgeDollarSign size={20} />
<span className="text-xs mt-1">Payments</span>
</Link>
</ToolTip>
<ToolTip content={'Users'} slateBlack sideOffset={8} side="top">
<Link href={`/dash/users/settings/users`} className="flex flex-col items-center p-2">
<Users size={20} />

View file

@ -1,11 +1,11 @@
'use client';
import { useOrg } from '@components/Contexts/OrgContext';
import { Backpack, Book, ChevronRight, School, User, Users } from 'lucide-react'
import { Backpack, Book, ChevronRight, CreditCard, School, User, Users } from 'lucide-react'
import Link from 'next/link'
import React from 'react'
type BreadCrumbsProps = {
type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments'
type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments' | 'payments'
last_breadcrumb?: string
}
@ -65,6 +65,15 @@ function BreadCrumbs(props: BreadCrumbsProps) {
) : (
''
)}
{props.type == 'payments' ? (
<div className="flex space-x-2 items-center">
{' '}
<CreditCard className="text-gray" size={14}></CreditCard>
<Link href="/dash/payments">Payments</Link>
</div>
) : (
''
)}
<div className="flex items-center space-x-1 first-letter:uppercase">
{props.last_breadcrumb ? <ChevronRight size={17} /> : ''}
<div className="first-letter:uppercase">

View file

@ -1,7 +1,7 @@
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'
import LinkToUserGroup from '@components/Objects/Modals/Dash/EditCourseAccess/LinkToUserGroup'
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import Modal from '@components/StyledElements/Modal/Modal'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
import { getAPIUrl } from '@services/config/config'
import { unLinkResourcesToUserGroup } from '@services/usergroups/usergroups'
import { swrFetcher } from '@services/utils/ts/requests'

View file

@ -3,13 +3,13 @@ import FormLayout, {
FormLabelAndMessage,
Input,
Textarea,
} from '@components/StyledElements/Form/Form';
} from '@components/Objects/StyledElements/Form/Form';
import { useFormik } from 'formik';
import { AlertTriangle } from 'lucide-react';
import * as Form from '@radix-ui/react-form';
import React, { useEffect, useState } from 'react';
import { useCourse, useCourseDispatch } from '../../../Contexts/CourseContext';
import ThumbnailUpdate from './ThumbnailUpdate';
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
type EditCourseStructureProps = {
orgslug: string

View file

@ -3,6 +3,7 @@ import { createApi } from 'unsplash-js';
import { Search, X, Cpu, Briefcase, GraduationCap, Heart, Palette, Plane, Utensils,
Dumbbell, Music, Shirt, Book, Building, Bike, Camera, Microscope, Coins, Coffee, Gamepad,
Flower} from 'lucide-react';
import Modal from '@components/Objects/StyledElements/Modal/Modal';
const unsplash = createApi({
accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY as string,
@ -36,9 +37,10 @@ const predefinedLabels = [
interface UnsplashImagePickerProps {
onSelect: (imageUrl: string) => void;
onClose: () => void;
isOpen?: boolean;
}
const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onClose }) => {
const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onClose, isOpen = true }) => {
const [query, setQuery] = useState('');
const [images, setImages] = useState<any[]>([]);
const [page, setPage] = useState(1);
@ -54,8 +56,6 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
});
if (result && result.response) {
setImages(prevImages => pageNum === 1 ? result.response.results : [...prevImages, ...result.response.results]);
} else {
console.error('Unexpected response structure:', result);
}
} catch (error) {
console.error('Error fetching images:', error);
@ -97,16 +97,10 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-3/4 max-w-4xl max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">Choose an image from Unsplash</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
<X size={24} />
</button>
</div>
<div className="relative mb-4">
const modalContent = (
<div className="flex flex-col h-full">
<div className="p-4 space-y-4">
<div className="relative">
<input
type="text"
value={query}
@ -116,7 +110,7 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
/>
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
</div>
<div className="flex flex-wrap gap-2 mb-4">
<div className="flex flex-wrap gap-2">
{predefinedLabels.map(label => (
<button
key={label.name}
@ -128,6 +122,9 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 pt-0">
<div className="grid grid-cols-3 gap-4">
{images.map(image => (
<div key={image.id} className="relative w-full pb-[56.25%]">
@ -135,7 +132,7 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
src={image.urls.small}
alt={image.alt_description}
className="absolute inset-0 w-full h-full object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => handleImageSelect(image.urls.full)}
onClick={() => handleImageSelect(image.urls.regular)}
/>
</div>
))}
@ -144,7 +141,7 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
{!loading && images.length > 0 && (
<button
onClick={handleLoadMore}
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
className="mt-4 w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
>
Load More
</button>
@ -152,6 +149,18 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
</div>
</div>
);
return (
<Modal
dialogTitle="Choose an image from Unsplash"
dialogContent={modalContent}
onOpenChange={onClose}
isDialogOpen={isOpen}
minWidth="lg"
minHeight="lg"
customHeight="h-[80vh]"
/>
);
};
// Custom debounce function

View file

@ -1,6 +1,6 @@
import { useCourse } from '@components/Contexts/CourseContext'
import NewActivityModal from '@components/Objects/Modals/Activities/Create/NewActivity'
import Modal from '@components/StyledElements/Modal/Modal'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
import { getAPIUrl } from '@services/config/config'
import {
createActivity,

View file

@ -1,4 +1,4 @@
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import { getAPIUrl, getUriWithOrg } from '@services/config/config'
import { deleteActivity, updateActivity } from '@services/courses/activities'
import { revalidateTags } from '@services/utils/ts/requests'
@ -7,6 +7,7 @@ import {
Eye,
File,
FilePenLine,
FileSymlink,
Globe,
Lock,
MoreVertical,
@ -27,6 +28,7 @@ import { useOrg } from '@components/Contexts/OrgContext'
import { useCourse } from '@components/Contexts/CourseContext'
import toast from 'react-hot-toast'
import { useMediaQuery } from 'usehooks-ts'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
type ActivitiyElementProps = {
orgslug: string
@ -176,6 +178,8 @@ function ActivityElement(props: ActivitiyElementProps) {
)}
<span>{!props.activity.published ? 'Publish' : 'Unpublish'}</span>
</button>
<div className="w-px h-3 bg-gray-300 mx-1 self-center rounded-full hidden sm:block" />
<ToolTip content="Preview Activity" sideOffset={8}>
<Link
href={
getUriWithOrg(props.orgslug, '') +
@ -191,9 +195,9 @@ function ActivityElement(props: ActivitiyElementProps) {
className="p-1 px-2 sm:px-3 bg-gradient-to-bl text-cyan-800 from-sky-400/50 to-cyan-200/80 border border-cyan-600/10 shadow-md rounded-md font-bold text-xs flex items-center space-x-1 transition-colors duration-200 hover:from-sky-500/50 hover:to-cyan-300/80"
rel="noopener noreferrer"
>
<Eye strokeWidth={2} size={12} className="text-sky-600" />
<span>Preview</span>
<Eye strokeWidth={2} size={14} className="text-sky-600" />
</Link>
</ToolTip>
{/* Delete Button */}
<ConfirmationModal
confirmationMessage="Are you sure you want to delete this activity ?"
@ -205,7 +209,6 @@ function ActivityElement(props: ActivitiyElementProps) {
rel="noopener noreferrer"
>
<X size={15} className="text-rose-200 font-bold" />
{!isMobile && <span className="text-rose-200 font-bold text-xs">Delete</span>}
</button>
}
functionToExecute={() => deleteActivityUI()}

View file

@ -1,4 +1,4 @@
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
import {
Hexagon,
MoreHorizontal,

View file

@ -13,7 +13,7 @@ import {
useCourseDispatch,
} from '@components/Contexts/CourseContext'
import { Hexagon } from 'lucide-react'
import Modal from '@components/StyledElements/Modal/Modal'
import Modal from '@components/Objects/StyledElements/Modal/Modal'
import NewChapterModal from '@components/Objects/Modals/Chapters/NewChapter'
import { useLHSession } from '@components/Contexts/LHSessionContext'

View file

@ -12,7 +12,7 @@ import { useRouter } from 'next/navigation'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getOrgLogoMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media'
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs"
import { Toaster, toast } from 'react-hot-toast';
import { constructAcceptValue } from '@/lib/constants';

View file

@ -0,0 +1,290 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useOrg } from '@components/Contexts/OrgContext';
import { SiStripe } from '@icons-pack/react-simple-icons'
import { useLHSession } from '@components/Contexts/LHSessionContext';
import { getPaymentConfigs, initializePaymentConfig, updatePaymentConfig, deletePaymentConfig, updateStripeAccountID, getStripeOnboardingLink } from '@services/payments/payments';
import FormLayout, { ButtonBlack, Input, Textarea, FormField, FormLabelAndMessage, Flex } from '@components/Objects/StyledElements/Form/Form';
import { AlertTriangle, BarChart2, Check, Coins, CreditCard, Edit, ExternalLink, Info, Loader2, RefreshCcw, Trash2, UnplugIcon } from 'lucide-react';
import toast from 'react-hot-toast';
import useSWR, { mutate } from 'swr';
import Modal from '@components/Objects/StyledElements/Modal/Modal';
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal';
import { Button } from '@components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@components/ui/alert';
import { useRouter } from 'next/navigation';
import { getUriWithoutOrg } from '@services/config/config';
const PaymentsConfigurationPage: React.FC = () => {
const org = useOrg() as any;
const session = useLHSession() as any;
const router = useRouter();
const access_token = session?.data?.tokens?.access_token;
const { data: paymentConfigs, error, isLoading } = useSWR(
() => (org && access_token ? [`/payments/${org.id}/config`, access_token] : null),
([url, token]) => getPaymentConfigs(org.id, token)
);
const stripeConfig = paymentConfigs?.find((config: any) => config.provider === 'stripe');
const [isModalOpen, setIsModalOpen] = useState(false);
const [isOnboarding, setIsOnboarding] = useState(false);
const [isOnboardingLoading, setIsOnboardingLoading] = useState(false);
const enableStripe = async () => {
try {
setIsOnboarding(true);
const newConfig = { provider: 'stripe', enabled: true };
const config = await initializePaymentConfig(org.id, newConfig, 'stripe', access_token);
toast.success('Stripe enabled successfully');
mutate([`/payments/${org.id}/config`, access_token]);
} catch (error) {
console.error('Error enabling Stripe:', error);
toast.error('Failed to enable Stripe');
} finally {
setIsOnboarding(false);
}
};
const editConfig = async () => {
setIsModalOpen(true);
};
const deleteConfig = async () => {
try {
await deletePaymentConfig(org.id, stripeConfig.id, access_token);
toast.success('Stripe configuration deleted successfully');
mutate([`/payments/${org.id}/config`, access_token]);
} catch (error) {
console.error('Error deleting Stripe configuration:', error);
toast.error('Failed to delete Stripe configuration');
}
};
const handleStripeOnboarding = async () => {
try {
setIsOnboardingLoading(true);
const { connect_url } = await getStripeOnboardingLink(org.id, access_token, getUriWithoutOrg('/payments/stripe/connect/oauth'));
window.open(connect_url, '_blank');
} catch (error) {
console.error('Error getting onboarding link:', error);
toast.error('Failed to start Stripe onboarding');
} finally {
setIsOnboardingLoading(false);
}
};
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error loading payment configuration</div>;
}
return (
<div>
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-xl text-gray-800">Payments Configuration</h1>
<h2 className="text-gray-500 text-md">Manage your organization payments configuration</h2>
</div>
<Alert className="mb-3 p-6 border-2 border-blue-100 bg-blue-50/50">
<AlertTitle className="text-lg font-semibold mb-2 flex items-center space-x-2"> <Info className="h-5 w-5 " /> <span>About the Stripe Integration</span></AlertTitle>
<AlertDescription className="space-y-5">
<div className="pl-2">
<ul className="list-disc list-inside space-y-1 text-gray-600 pl-2">
<li className="flex items-center space-x-2">
<CreditCard className="h-4 w-4" />
<span>Accept payments for courses and subscriptions</span>
</li>
<li className="flex items-center space-x-2">
<RefreshCcw className="h-4 w-4" />
<span>Manage recurring billing and subscriptions</span>
</li>
<li className="flex items-center space-x-2">
<Coins className="h-4 w-4" />
<span>Handle multiple currencies and payment methods</span>
</li>
<li className="flex items-center space-x-2">
<BarChart2 className="h-4 w-4" />
<span>Access detailed payment analytics</span>
</li>
</ul>
</div>
<a
href="https://stripe.com/docs"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 inline-flex items-center font-medium transition-colors duration-200 pl-2"
>
Learn more about Stripe
<ExternalLink className="ml-1.5 h-4 w-4" />
</a>
</AlertDescription>
</Alert>
<div className="flex flex-col rounded-lg light-shadow">
{stripeConfig ? (
<div className="flex items-center justify-between bg-gradient-to-r from-indigo-500 to-purple-600 p-6 rounded-lg shadow-md">
<div className="flex items-center space-x-3">
<SiStripe className="text-white" size={32} />
<div className="flex flex-col">
<div className="flex items-center space-x-2">
<span className="text-xl font-semibold text-white">Stripe</span>
{stripeConfig.provider_specific_id && stripeConfig.active ? (
<div className="flex items-center space-x-1 bg-green-500/20 px-2 py-0.5 rounded-full">
<div className="h-2 w-2 bg-green-500 rounded-full" />
<span className="text-xs text-green-100">Connected</span>
</div>
) : (
<div className="flex items-center space-x-1 bg-red-500/20 px-2 py-0.5 rounded-full">
<div className="h-2 w-2 bg-red-500 rounded-full" />
<span className="text-xs text-red-100">Not Connected</span>
</div>
)}
</div>
<span className="text-white/80 text-sm">
{stripeConfig.provider_specific_id ?
`Linked Account: ${stripeConfig.provider_specific_id}` :
'Account ID not configured'}
</span>
</div>
</div>
<div className="flex space-x-2">
{(!stripeConfig.provider_specific_id || !stripeConfig.active) && (
<Button
onClick={handleStripeOnboarding}
className="flex items-center space-x-2 px-4 py-2 bg-green-500 text-white text-sm rounded-full hover:bg-green-600 transition duration-300 disabled:opacity-50 disabled:cursor-not-allowed border-2 border-green-400 shadow-md"
disabled={isOnboardingLoading}
>
{isOnboardingLoading ? (
<Loader2 className="animate-spin h-4 w-4" />
) : (
<UnplugIcon className="h-3 w-3" />
)}
<span className="font-semibold">Connect with Stripe</span>
</Button>
)}
<ConfirmationModal
confirmationButtonText="Remove Connection"
confirmationMessage="Are you sure you want to remove the Stripe connection? This action cannot be undone."
dialogTitle="Remove Stripe Connection"
dialogTrigger={
<Button
className="flex items-center space-x-2 bg-red-500 text-white text-sm rounded-full hover:bg-red-600 transition duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Trash2 size={16} />
<span>Remove Connection</span>
</Button>
}
functionToExecute={deleteConfig}
status="warning"
/>
</div>
</div>
) : (
<Button
onClick={enableStripe}
className="flex items-center justify-center space-x-2 bg-gradient-to-r p-3 from-indigo-500 to-purple-600 text-white px-6 rounded-lg hover:from-indigo-600 hover:to-purple-700 transition duration-300 shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
disabled={isOnboarding}
>
{isOnboarding ? (
<>
<Loader2 className="animate-spin" size={24} />
<span className="text-lg font-semibold">Connecting to Stripe...</span>
</>
) : (
<>
<SiStripe size={24} />
<span className="text-lg font-semibold">Enable Stripe</span>
</>
)}
</Button>
)}
</div>
</div>
{stripeConfig && (
<EditStripeConfigModal
orgId={org.id}
configId={stripeConfig.id}
accessToken={access_token}
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
/>
)}
</div>
);
};
interface EditStripeConfigModalProps {
orgId: number;
configId: string;
accessToken: string;
isOpen: boolean;
onClose: () => void;
}
const EditStripeConfigModal: React.FC<EditStripeConfigModalProps> = ({ orgId, configId, accessToken, isOpen, onClose }) => {
const [stripeAccountId, setStripeAccountId] = useState('');
useEffect(() => {
const fetchConfig = async () => {
try {
const config = await getPaymentConfigs(orgId, accessToken);
const stripeConfig = config.find((c: any) => c.id === configId);
if (stripeConfig && stripeConfig.provider_specific_id) {
setStripeAccountId(stripeConfig.provider_specific_id || '');
}
} catch (error) {
console.error('Error fetching Stripe configuration:', error);
toast.error('Failed to load existing configuration');
}
};
if (isOpen) {
fetchConfig();
}
}, [isOpen, orgId, configId, accessToken]);
const handleSubmit = async () => {
try {
const stripe_config = {
stripe_account_id: stripeAccountId,
};
await updateStripeAccountID(orgId, stripe_config, accessToken);
toast.success('Configuration updated successfully');
mutate([`/payments/${orgId}/config`, accessToken]);
onClose();
} catch (error) {
console.error('Error updating config:', error);
toast.error('Failed to update configuration');
}
};
return (
<Modal isDialogOpen={isOpen} dialogTitle="Edit Stripe Configuration" dialogDescription='Edit your stripe configuration' onOpenChange={onClose}
dialogContent={
<FormLayout onSubmit={handleSubmit}>
<FormField name="stripe-account-id">
<FormLabelAndMessage label="Stripe Account ID" />
<Input
type="text"
value={stripeAccountId}
onChange={(e) => setStripeAccountId(e.target.value)}
placeholder="acct_..."
/>
</FormField>
<Flex css={{ marginTop: 25, justifyContent: 'flex-end' }}>
<ButtonBlack type="submit" className="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600 transition duration-300">
Save
</ButtonBlack>
</Flex>
</FormLayout>
}
/>
);
};
export default PaymentsConfigurationPage;

View file

@ -0,0 +1,155 @@
import React from 'react'
import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import useSWR from 'swr'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@components/ui/table"
import { getOrgCustomers } from '@services/payments/payments'
import { Badge } from '@components/ui/badge'
import PageLoading from '@components/Objects/Loaders/PageLoading'
import { RefreshCcw, SquareCheck } from 'lucide-react'
import { getUserAvatarMediaDirectory } from '@services/media/media'
import UserAvatar from '@components/Objects/UserAvatar'
import { usePaymentsEnabled } from '@hooks/usePaymentsEnabled'
import UnconfiguredPaymentsDisclaimer from '@components/Pages/Payments/UnconfiguredPaymentsDisclaimer'
interface PaymentUserData {
payment_user_id: number;
user: {
username: string;
first_name: string;
last_name: string;
email: string;
avatar_image: string;
user_uuid: string;
};
product: {
name: string;
description: string;
product_type: string;
amount: number;
currency: string;
};
status: string;
creation_date: string;
}
function PaymentsUsersTable({ data }: { data: PaymentUserData[] }) {
if (!data || data.length === 0) {
return (
<div className="text-center py-8 text-gray-500">
No customers found
</div>
);
}
return (
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Product</TableHead>
<TableHead>Type</TableHead>
<TableHead>Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead>Purchase Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.map((item) => (
<TableRow key={item.payment_user_id}>
<TableCell className="font-medium">
<div className="flex items-center space-x-3">
<UserAvatar
border="border-2"
rounded="rounded-md"
avatar_url={getUserAvatarMediaDirectory(item.user.user_uuid, item.user.avatar_image)}
/>
<div className="flex flex-col">
<span className="font-medium">
{item.user.first_name || item.user.username}
</span>
<span className="text-sm text-gray-500">{item.user.email}</span>
</div>
</div>
</TableCell>
<TableCell>{item.product.name}</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
{item.product.product_type === 'subscription' ? (
<Badge variant="outline" className="flex items-center gap-1">
<RefreshCcw size={12} />
<span>Subscription</span>
</Badge>
) : (
<Badge variant="outline" className="flex items-center gap-1">
<SquareCheck size={12} />
<span>One-time</span>
</Badge>
)}
</div>
</TableCell>
<TableCell>
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: item.product.currency
}).format(item.product.amount)}
</TableCell>
<TableCell>
<Badge
variant={item.status === 'active' ? 'default' :
item.status === 'completed' ? 'default' : 'secondary'}
>
{item.status}
</Badge>
</TableCell>
<TableCell>
{new Date(item.creation_date).toLocaleDateString()}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}
function PaymentsCustomersPage() {
const org = useOrg() as any
const session = useLHSession() as any
const access_token = session?.data?.tokens?.access_token
const { isEnabled, isLoading } = usePaymentsEnabled()
const { data: customers, error, isLoading: customersLoading } = useSWR(
org ? [`/payments/${org.id}/customers`, access_token] : null,
([url, token]) => getOrgCustomers(org.id, token)
)
if (!isEnabled && !isLoading) {
return (
<UnconfiguredPaymentsDisclaimer />
)
}
if (isLoading || customersLoading) return <PageLoading />
if (error) return <div>Error loading customers</div>
if (!customers) return <div>No customer data available</div>
return (
<div className="ml-10 mr-10 mx-auto bg-white rounded-xl shadow-sm px-4 py-4">
<div className="flex flex-col bg-gray-50 -space-y-1 px-5 py-3 rounded-md mb-3">
<h1 className="font-bold text-xl text-gray-800">Customers</h1>
<h2 className="text-gray-500 text-md">View and manage your customer information</h2>
</div>
<PaymentsUsersTable data={customers} />
</div>
)
}
export default PaymentsCustomersPage

Some files were not shown because too many files have changed in this diff Show more