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 name: App Build
on: on:
push: push:
branches:
- dev
paths: paths:
- "**" - "**"
pull_request: pull_request:
@ -14,3 +16,14 @@ jobs:
- name: Build Docker Image - name: Build Docker Image
run: docker build -t learnhouse . run: docker build -t learnhouse .
working-directory: . 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", slug="default",
email="", email="",
logo_image="", logo_image="",
thumbnail_image="",
) )
install_create_organization(org, db_session) install_create_organization(org, db_session)
print("Default organization created ✅") print("Default organization created ✅")
@ -89,6 +90,7 @@ def install(
slug=slug.lower(), slug=slug.lower(),
email="", email="",
logo_image="", logo_image="",
thumbnail_image="",
) )
install_create_organization(org, db_session) install_create_organization(org, db_session)
print(orgname + " Organization created ✅") print(orgname + " Organization created ✅")

View file

@ -71,6 +71,18 @@ class RedisConfig(BaseModel):
redis_connection_string: Optional[str] 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): class LearnHouseConfig(BaseModel):
site_name: str site_name: str
site_description: str site_description: str
@ -82,6 +94,7 @@ class LearnHouseConfig(BaseModel):
security_config: SecurityConfig security_config: SecurityConfig
ai_config: AIConfig ai_config: AIConfig
mailing_config: MailingConfig mailing_config: MailingConfig
payments_config: InternalPaymentsConfig
def get_learnhouse_config() -> LearnHouseConfig: def get_learnhouse_config() -> LearnHouseConfig:
@ -261,6 +274,33 @@ def get_learnhouse_config() -> LearnHouseConfig:
else: else:
sentry_config = None 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 # Create HostingConfig and DatabaseConfig objects
hosting_config = HostingConfig( hosting_config = HostingConfig(
domain=domain, domain=domain,
@ -303,6 +343,15 @@ def get_learnhouse_config() -> LearnHouseConfig:
mailing_config=MailingConfig( mailing_config=MailingConfig(
resend_api_key=resend_api_key, system_email_address=system_email_address 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 return config

View file

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

View file

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

View file

@ -1,26 +1,54 @@
import logging import logging
import os
import importlib
from config.config import get_learnhouse_config from config.config import get_learnhouse_config
from fastapi import FastAPI from fastapi import FastAPI
from sqlmodel import SQLModel, Session, create_engine 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() learnhouse_config = get_learnhouse_config()
engine = create_engine( 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): async def connect_to_db(app: FastAPI):
app.db_engine = engine # type: ignore app.db_engine = engine # type: ignore
logging.info("LearnHouse database has been started.") logging.info("LearnHouse database has been started.")
SQLModel.metadata.create_all(engine) SQLModel.metadata.create_all(engine)
def get_db_session(): def get_db_session():
with Session(engine) as session: with Session(engine) as session:
yield session yield session
async def close_database(app: FastAPI): async def close_database(app: FastAPI):
logging.info("LearnHouse has been shut down.") logging.info("LearnHouse has been shut down.")
return app return app

View file

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

View file

@ -40,7 +40,6 @@ class AssignmentOrgConfig(BaseModel):
class PaymentOrgConfig(BaseModel): class PaymentOrgConfig(BaseModel):
enabled: bool = True enabled: bool = True
stripe_key: str = ""
class DiscussionOrgConfig(BaseModel): class DiscussionOrgConfig(BaseModel):
@ -91,7 +90,7 @@ class OrgCloudConfig(BaseModel):
# Main Config # Main Config
class OrganizationConfigBase(BaseModel): class OrganizationConfigBase(BaseModel):
config_version: str = "1.1" config_version: str = "1.2"
general: OrgGeneralConfig general: OrgGeneralConfig
features: OrgFeatureConfig features: OrgFeatureConfig
cloud: OrgCloudConfig 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" user_uuid: str = "user_anonymous"
username: str = "anonymous" username: str = "anonymous"
class InternalUser(SQLModel):
id: int = 0
user_uuid: str = "user_internal"
username: str = "internal"
class User(UserBase, table=True): class User(UserBase, table=True):
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)

View file

@ -1,11 +1,12 @@
import os import os
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from src.routers import health
from src.routers import usergroups from src.routers import usergroups
from src.routers import dev, trail, users, auth, orgs, roles from src.routers import dev, trail, users, auth, orgs, roles
from src.routers.ai import ai from src.routers.ai import ai
from src.routers.courses import chapters, collections, courses, assignments from src.routers.courses import chapters, collections, courses, assignments
from src.routers.courses.activities import activities, blocks 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.routers.install import install
from src.services.dev.dev import isDevModeEnabledOrRaise from src.services.dev.dev import isDevModeEnabledOrRaise
from src.services.install.install import isInstallModeEnabled 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(trail.router, prefix="/trail", tags=["trail"])
v1_router.include_router(ai.router, prefix="/ai", tags=["ai"]) 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"): if os.environ.get("CLOUD_INTERNAL_KEY"):
v1_router.include_router( v1_router.include_router(
@ -41,6 +43,8 @@ if os.environ.get("CLOUD_INTERNAL_KEY"):
dependencies=[Depends(cloud_internal.check_internal_cloud_key)], dependencies=[Depends(cloud_internal.check_internal_cloud_key)],
) )
v1_router.include_router(health.router, prefix="/health", tags=["health"])
# Dev Routes # Dev Routes
v1_router.include_router( v1_router.include_router(
dev.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 # 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, request: Request,
user_id: int, user_id: int,
action: Literal["read", "update", "delete", "create"], 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.courses import Course
from src.db.courses.chapters import Chapter from src.db.courses.chapters import Chapter
from src.security.rbac.rbac import ( 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_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -14,6 +14,8 @@ from fastapi import HTTPException, Request
from uuid import uuid4 from uuid import uuid4
from datetime import datetime from datetime import datetime
from src.services.payments.payments_access import check_activity_paid_access
#################################################### ####################################################
# CRUD # CRUD
@ -112,7 +114,16 @@ async def get_activity(
# RBAC check # RBAC check
await rbac_check(request, course.course_uuid, current_user, "read", db_session) 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 return activity
@ -258,30 +269,32 @@ async def get_activities(
async def rbac_check( async def rbac_check(
request: Request, request: Request,
course_uuid: str, element_uuid: str,
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser,
action: Literal["create", "read", "update", "delete"], action: Literal["create", "read", "update", "delete"],
db_session: Session, db_session: Session,
): ):
if action == "read": if action == "read":
if current_user.id == 0: # Anonymous user if current_user.id == 0: # Anonymous user
res = await authorization_verify_if_element_is_public( res = await authorization_verify_if_element_is_public(
request, course_uuid, action, db_session request, element_uuid, action, db_session
) )
return res return res
else: 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 request, current_user.id, action, element_uuid, db_session
) )
return res return res
else: else:
# For non-read actions, proceed with regular RBAC checks
await authorization_verify_if_user_is_anon(current_user.id) await authorization_verify_if_user_is_anon(current_user.id)
await authorization_verify_based_on_roles_and_authorship(
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
request, request,
current_user.id, current_user.id,
action, action,
course_uuid, element_uuid,
db_session, db_session,
) )

View file

@ -34,7 +34,7 @@ from src.security.features_utils.usage import (
increase_feature_usage, increase_feature_usage,
) )
from src.security.rbac.rbac import ( 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_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -1666,7 +1666,7 @@ async def rbac_check(
return res return res
else: else:
res = ( 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 request, current_user.id, action, course_uuid, db_session
) )
) )
@ -1674,7 +1674,7 @@ async def rbac_check(
else: else:
await authorization_verify_if_user_is_anon(current_user.id) 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, request,
current_user.id, current_user.id,
action, action,

View file

@ -3,7 +3,7 @@ from src.db.courses.courses import Course
from src.db.organizations import Organization from src.db.organizations import Organization
from sqlmodel import Session, select from sqlmodel import Session, select
from src.security.rbac.rbac import ( 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, authorization_verify_if_user_is_anon,
) )
from src.db.courses.chapters import Chapter 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_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, request,
current_user.id, current_user.id,
action, action,

View file

@ -5,7 +5,7 @@ from src.db.organizations import Organization
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Session, select from sqlmodel import Session, select
from src.security.rbac.rbac import ( 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, authorization_verify_if_user_is_anon,
) )
from src.db.courses.chapters import Chapter 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_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, request,
current_user.id, current_user.id,
action, action,

View file

@ -4,7 +4,7 @@ from uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.users import AnonymousUser from src.db.users import AnonymousUser
from src.security.rbac.rbac import ( 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_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -561,14 +561,14 @@ async def rbac_check(
) )
return res return res
else: 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 request, current_user.id, action, course_uuid, db_session
) )
return res return res
else: else:
await authorization_verify_if_user_is_anon(current_user.id) 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, request,
current_user.id, current_user.id,
action, action,

View file

@ -4,7 +4,7 @@ from uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.db.users import AnonymousUser from src.db.users import AnonymousUser
from src.security.rbac.rbac import ( 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_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -300,7 +300,7 @@ async def rbac_check(
) )
else: else:
res = ( 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 request, current_user.id, action, collection_uuid, db_session
) )
) )
@ -308,7 +308,7 @@ async def rbac_check(
else: else:
await authorization_verify_if_user_is_anon(current_user.id) 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, request,
current_user.id, current_user.id,
action, action,

View file

@ -1,7 +1,6 @@
from typing import Literal from typing import Literal, List
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import union from sqlmodel import Session, select, or_, and_
from sqlmodel import Session, select
from src.db.usergroup_resources import UserGroupResource from src.db.usergroup_resources import UserGroupResource
from src.db.usergroup_user import UserGroupUser from src.db.usergroup_user import UserGroupUser
from src.db.organizations import Organization from src.db.organizations import Organization
@ -21,7 +20,7 @@ from src.db.courses.courses import (
FullCourseReadWithTrail, FullCourseReadWithTrail,
) )
from src.security.rbac.rbac import ( 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_element_is_public,
authorization_verify_if_user_is_anon, authorization_verify_if_user_is_anon,
) )
@ -151,6 +150,69 @@ async def get_course_meta(
trail=trail if trail else None, 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( async def create_course(
request: Request, request: Request,
@ -366,72 +428,7 @@ async def delete_course(
return {"detail": "Course deleted"} 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 ## ## 🔒 RBAC Utils ##
@ -452,7 +449,7 @@ async def rbac_check(
return res return res
else: else:
res = ( 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 request, current_user.id, action, course_uuid, db_session
) )
) )
@ -460,7 +457,7 @@ async def rbac_check(
else: else:
await authorization_verify_if_user_is_anon(current_user.id) 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, request,
current_user.id, current_user.id,
action, 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
org_config = OrganizationConfigBase( org_config = OrganizationConfigBase(
config_version="1.1", config_version="1.2",
general=OrgGeneralConfig( general=OrgGeneralConfig(
enabled=True, enabled=True,
color="normal", color="normal",
@ -345,7 +345,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess
storage=StorageOrgConfig(enabled=True, limit=0), storage=StorageOrgConfig(enabled=True, limit=0),
ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"), ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"),
assignments=AssignmentOrgConfig(enabled=True, limit=0), assignments=AssignmentOrgConfig(enabled=True, limit=0),
payments=PaymentOrgConfig(enabled=True, stripe_key=""), payments=PaymentOrgConfig(enabled=False),
discussions=DiscussionOrgConfig(enabled=True, limit=0), discussions=DiscussionOrgConfig(enabled=True, limit=0),
analytics=AnalyticsOrgConfig(enabled=True, limit=0), analytics=AnalyticsOrgConfig(enabled=True, limit=0),
collaboration=CollaborationOrgConfig(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_based_on_org_admin_status,
authorization_verify_if_user_is_anon, 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.user_organizations import UserOrganization
from src.db.organizations import ( from src.db.organizations import (
Organization, Organization,
@ -682,13 +682,17 @@ async def get_org_join_mechanism(
async def rbac_check( async def rbac_check(
request: Request, request: Request,
org_uuid: str, org_uuid: str,
current_user: PublicUser | AnonymousUser, current_user: PublicUser | AnonymousUser | InternalUser,
action: Literal["create", "read", "update", "delete"], action: Literal["create", "read", "update", "delete"],
db_session: Session, db_session: Session,
): ):
# Organizations are readable by anyone # Organizations are readable by anyone
if action == "read": if action == "read":
return True return True
# Internal users can do anything
if isinstance(current_user, InternalUser):
return True
else: else:
isUserAnon = await authorization_verify_if_user_is_anon(current_user.id) 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 uuid import uuid4
from sqlmodel import Session, select from sqlmodel import Session, select
from src.security.rbac.rbac import ( 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, authorization_verify_if_user_is_anon,
) )
from src.db.users import AnonymousUser, PublicUser 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_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 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, increase_feature_usage,
) )
from src.security.rbac.rbac import ( 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, authorization_verify_if_user_is_anon,
) )
from src.db.usergroup_resources import UserGroupResource 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_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, request,
current_user.id, current_user.id,
action, 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.services.users.avatars import upload_avatar
from src.db.roles import Role, RoleRead from src.db.roles import Role, RoleRead
from src.security.rbac.rbac import ( 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, authorization_verify_if_user_is_anon,
) )
from src.db.organizations import Organization, OrganizationRead from src.db.organizations import Organization, OrganizationRead
@ -491,7 +491,7 @@ async def authorize_user_action(
# RBAC check # RBAC check
authorized = ( 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 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 if current_user.id == 0: # if user is anonymous
return True return True
else: 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 request, current_user.id, "create", "user_x", db_session
) )
@ -575,7 +575,7 @@ async def rbac_check(
if current_user.user_uuid == user_uuid: if current_user.user_uuid == user_uuid:
return True 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 request, current_user.id, action, user_uuid, db_session
) )

View file

@ -8,7 +8,7 @@ from src.services.install.install import (
install_default_elements, install_default_elements,
) )
# TODO: Depreceated and need to be removed and remade
async def create_initial_data_for_tests(db_session: Session): async def create_initial_data_for_tests(db_session: Session):
# Install default elements # Install default elements
await install_default_elements({}, db_session) 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, FormField,
FormLabelAndMessage, FormLabelAndMessage,
Input, Input,
} from '@components/StyledElements/Form/Form' } from '@components/Objects/StyledElements/Form/Form'
import * as Form from '@radix-ui/react-form' import * as Form from '@radix-ui/react-form'
import { getOrgLogoMediaDirectory } from '@services/media/media' import { getOrgLogoMediaDirectory } from '@services/media/media'
import { AlertTriangle, Info } from 'lucide-react' import { AlertTriangle, Info } from 'lucide-react'

View file

@ -1,6 +1,6 @@
'use client' 'use client'
import { OrgProvider } from '@components/Contexts/OrgContext' 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' import { useSearchParams } from 'next/navigation'

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
'use client' 'use client'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { INSTALL_STEPS } from './steps/steps' 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 { useRouter, useSearchParams } from 'next/navigation'
import { Suspense } from 'react' import { Suspense } from 'react'

View file

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

View file

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

View file

@ -1,6 +1,6 @@
'use client' // Error components must be Client Components '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' import { useEffect } from 'react'
export default function Error({ 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 { getUriWithOrg } from '@services/config/config'
import { getCollectionById } from '@services/courses/collections' import { getCollectionById } from '@services/courses/collections'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' import { getCourseThumbnailMediaDirectory } from '@services/media/media'

View file

@ -1,17 +1,17 @@
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle' import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { getOrganizationContextInfo } from '@services/organizations/orgs' import { getOrganizationContextInfo } from '@services/organizations/orgs'
import { Metadata } from 'next' import { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail' import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail'
import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton' import NewCollectionButton from '@components/Objects/StyledElements/Buttons/NewCollectionButton'
import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder'
import { nextAuthOptions } from 'app/auth/options' import { nextAuthOptions } from 'app/auth/options'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { getOrgCollections } from '@services/courses/collections' import { getOrgCollections } from '@services/courses/collections'
import { getOrgThumbnailMediaDirectory } from '@services/media/media' import { getOrgThumbnailMediaDirectory } from '@services/media/media'
import ContentPlaceHolderIfUserIsNotAdmin from '@components/Objects/ContentPlaceHolder'
type MetadataProps = { type MetadataProps = {
params: { orgslug: string; courseid: string } 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 { markActivityAsComplete } from '@services/courses/activity'
import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf' import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf'
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' 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 { usePathname, useRouter } from 'next/navigation'
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import { getCourseThumbnailMediaDirectory } from '@services/media/media' 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 AssignmentSubmissionProvider, { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { mutate } from 'swr' 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 { useMediaQuery } from 'usehooks-ts'
import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer'
interface ActivityClientProps { interface ActivityClientProps {
activityid: string activityid: string
@ -129,7 +130,7 @@ function ActivityClient(props: ActivityClientProps) {
</h1> </h1>
</div> </div>
<div className="flex space-x-1 items-center"> <div className="flex space-x-1 items-center">
{activity && activity.published == true && ( {activity && activity.published == true && activity.content.paid_access != false && (
<AuthenticatedClientElement checkMethod="authentication"> <AuthenticatedClientElement checkMethod="authentication">
{activity.activity_type != 'TYPE_ASSIGNMENT' && {activity.activity_type != 'TYPE_ASSIGNMENT' &&
<> <>
@ -173,40 +174,44 @@ function ActivityClient(props: ActivityClientProps) {
)} )}
{activity && activity.published == true && ( {activity && activity.published == true && (
<div <>
className={`p-7 drop-shadow-sm rounded-lg ${bgColor}`} {activity.content.paid_access == false ? (
> <PaidCourseActivityDisclaimer course={course} />
<div> ) : (
{activity.activity_type == 'TYPE_DYNAMIC' && ( <div className={`p-7 drop-shadow-sm rounded-lg ${bgColor}`}>
<Canva content={activity.content} activity={activity} /> {/* Activity Types */}
)}
{/* todo : use apis & streams instead of this */}
{activity.activity_type == 'TYPE_VIDEO' && (
<VideoActivity course={course} activity={activity} />
)}
{activity.activity_type == 'TYPE_DOCUMENT' && (
<DocumentPdfActivity
course={course}
activity={activity}
/>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<div> <div>
{assignment ? ( {activity.activity_type == 'TYPE_DYNAMIC' && (
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}> <Canva content={activity.content} activity={activity} />
<AssignmentsTaskProvider> )}
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}> {activity.activity_type == 'TYPE_VIDEO' && (
<AssignmentStudentActivity /> <VideoActivity course={course} activity={activity} />
</AssignmentSubmissionProvider> )}
</AssignmentsTaskProvider> {activity.activity_type == 'TYPE_DOCUMENT' && (
</AssignmentProvider> <DocumentPdfActivity
) : ( course={course}
<div></div> activity={activity}
/>
)}
{activity.activity_type == 'TYPE_ASSIGNMENT' && (
<div>
{assignment ? (
<AssignmentProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentsTaskProvider>
<AssignmentSubmissionProvider assignment_uuid={assignment?.assignment_uuid}>
<AssignmentStudentActivity />
</AssignmentSubmissionProvider>
</AssignmentsTaskProvider>
</AssignmentProvider>
) : (
<div></div>
)}
</div>
)} )}
</div> </div>
)} </div>
</div> )}
</div> </>
)} )}
{<div style={{ height: '100px' }}></div>} {<div style={{ height: '100px' }}></div>}
</div> </div>

View file

@ -1,6 +1,6 @@
'use client' // Error components must be Client Components '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' import { useEffect } from 'react'
export default function Error({ export default function Error({

View file

@ -1,5 +1,4 @@
'use client' 'use client'
import { removeCourse, startCourse } from '@services/courses/activity'
import Link from 'next/link' import Link from 'next/link'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { getUriWithOrg } from '@services/config/config' 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 { revalidateTags } from '@services/utils/ts/requests'
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators' import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { import {
getCourseThumbnailMediaDirectory, getCourseThumbnailMediaDirectory,
getUserAvatarMediaDirectory, getUserAvatarMediaDirectory,
@ -15,15 +14,13 @@ import {
import { ArrowRight, Backpack, Check, File, Sparkles, Video } from 'lucide-react' import { ArrowRight, Backpack, Check, File, Sparkles, Video } from 'lucide-react'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import UserAvatar from '@components/Objects/UserAvatar' 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 { CourseProvider } from '@components/Contexts/CourseContext'
import { useLHSession } from '@components/Contexts/LHSessionContext'
import { useMediaQuery } from 'usehooks-ts' import { useMediaQuery } from 'usehooks-ts'
import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions'
const CourseClient = (props: any) => { const CourseClient = (props: any) => {
const [user, setUser] = useState<any>({})
const [learnings, setLearnings] = useState<any>([]) const [learnings, setLearnings] = useState<any>([])
const session = useLHSession() as any;
const courseuuid = props.courseuuid const courseuuid = props.courseuuid
const orgslug = props.orgslug const orgslug = props.orgslug
const course = props.course const course = props.course
@ -37,33 +34,6 @@ const CourseClient = (props: any) => {
setLearnings(learnings) 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(() => { useEffect(() => {
getLearningTags() getLearningTags()
}, [org, course]) }, [org, course])
@ -80,7 +50,7 @@ const CourseClient = (props: any) => {
<h1 className="text-3xl md:text-3xl -mt-3 font-bold">{course.name}</h1> <h1 className="text-3xl md:text-3xl -mt-3 font-bold">{course.name}</h1>
</div> </div>
<div className="mt-4 md:mt-0"> <div className="mt-4 md:mt-0">
{!isMobile && <CourseProvider courseuuid={course.course_uuid}> {!isMobile && <CourseProvider courseuuid={course.course_uuid}>
<CourseUpdates /> <CourseUpdates />
</CourseProvider>} </CourseProvider>}
</div> </div>
@ -113,11 +83,11 @@ const CourseClient = (props: any) => {
course={course} course={course}
/> />
<div className="flex flex-col md:flex-row pt-10"> <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 grow space-y-2"> <div className="course_metadata_left w-full md:basis-3/4 space-y-2">
<h2 className="py-3 text-2xl font-bold">Description</h2> <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"> <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> </div>
{learnings.length > 0 && learnings[0] !== 'null' && ( {learnings.length > 0 && learnings[0] !== 'null' && (
@ -187,7 +157,7 @@ const CourseClient = (props: any) => {
/> />
</div> </div>
)} )}
{activity.activity_type === {activity.activity_type ===
'TYPE_ASSIGNMENT' && ( 'TYPE_ASSIGNMENT' && (
<div className="bg-gray-100 px-2 py-2 rounded-full"> <div className="bg-gray-100 px-2 py-2 rounded-full">
<Backpack <Backpack
@ -273,7 +243,7 @@ const CourseClient = (props: any) => {
</Link> </Link>
</> </>
)} )}
{activity.activity_type === {activity.activity_type ===
'TYPE_ASSIGNMENT' && ( 'TYPE_ASSIGNMENT' && (
<> <>
<Link <Link
@ -305,60 +275,8 @@ const CourseClient = (props: any) => {
})} })}
</div> </div>
</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"> <div className='course_metadata_right basis-1/4'>
{user && ( <CoursesActions courseuuid={courseuuid} orgslug={orgslug} course={course} />
<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> </div>
</div> </div>
</GeneralWrapperStyled> </GeneralWrapperStyled>

View file

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

View file

@ -1,6 +1,6 @@
'use client' // Error components must be Client Components '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' import { useEffect } from 'react'
export default function Error({ export default function Error({

View file

@ -1,6 +1,6 @@
'use client' // Error components must be Client Components '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' import { useEffect } from 'react'
export default function Error({ export default function Error({

View file

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

View file

@ -4,14 +4,14 @@ import { getUriWithOrg } from '@services/config/config'
import { getOrgCourses } from '@services/courses/courses' import { getOrgCourses } from '@services/courses/courses'
import Link from 'next/link' import Link from 'next/link'
import { getOrganizationContextInfo } from '@services/organizations/orgs' import { getOrganizationContextInfo } from '@services/organizations/orgs'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle' import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail' import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'
import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail' import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail'
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton' import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton'
import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton' import NewCollectionButton from '@components/Objects/StyledElements/Buttons/NewCollectionButton'
import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder' import ContentPlaceHolderIfUserIsNotAdmin from '@components/Objects/ContentPlaceHolder'
import { getOrgCollections } from '@services/courses/collections' import { getOrgCollections } from '@services/courses/collections'
import { getServerSession } from 'next-auth' import { getServerSession } from 'next-auth'
import { nextAuthOptions } from 'app/auth/options' 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 { useOrg } from '@components/Contexts/OrgContext'
import PageLoading from '@components/Objects/Loaders/PageLoading' import PageLoading from '@components/Objects/Loaders/PageLoading'
import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement' import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement'
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle' import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper' import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
import { getAPIUrl } from '@services/config/config' import { getAPIUrl } from '@services/config/config'
import { swrFetcher } from '@services/utils/ts/requests' import { swrFetcher } from '@services/utils/ts/requests'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'

View file

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

View file

@ -3,7 +3,7 @@ import { useAssignments } from '@components/Contexts/Assignments/AssignmentConte
import { useAssignmentsTask, useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext'; import { useAssignmentsTask, useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext';
import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useLHSession } from '@components/Contexts/LHSessionContext';
import { useOrg } from '@components/Contexts/OrgContext'; 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 * as Form from '@radix-ui/react-form';
import { getActivityByID } from '@services/courses/activities'; import { getActivityByID } from '@services/courses/activities';
import { updateAssignmentTask, updateReferenceFile } from '@services/courses/assignments'; import { updateAssignmentTask, updateReferenceFile } from '@services/courses/assignments';

View file

@ -1,5 +1,5 @@
import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext' 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 { FileUp, ListTodo, PanelLeftOpen, Plus } from 'lucide-react';
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import NewTaskModal from './Modals/NewTaskModal'; import NewTaskModal from './Modals/NewTaskModal';

View file

@ -1,9 +1,9 @@
'use client'; '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 { BookOpen, BookX, EllipsisVertical, Eye, Layers2, Monitor, UserRoundPen } from 'lucide-react'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { AssignmentProvider, useAssignments } from '@components/Contexts/Assignments/AssignmentContext'; 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 { updateAssignment } from '@services/courses/assignments';
import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useLHSession } from '@components/Contexts/LHSessionContext';
import { mutate } from 'swr'; import { mutate } from 'swr';

View file

@ -1,6 +1,6 @@
import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useLHSession } from '@components/Contexts/LHSessionContext';
import UserAvatar from '@components/Objects/UserAvatar'; 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 { getAPIUrl } from '@services/config/config';
import { getUserAvatarMediaDirectory } from '@services/media/media'; import { getUserAvatarMediaDirectory } from '@services/media/media';
import { swrFetcher } from '@services/utils/ts/requests'; import { swrFetcher } from '@services/utils/ts/requests';

View file

@ -1,7 +1,7 @@
'use client'; 'use client';
import { useLHSession } from '@components/Contexts/LHSessionContext'; import { useLHSession } from '@components/Contexts/LHSessionContext';
import { useOrg } from '@components/Contexts/OrgContext'; 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 { getAPIUrl, getUriWithOrg } from '@services/config/config';
import { getAssignmentsFromACourse } from '@services/courses/assignments'; import { getAssignmentsFromACourse } from '@services/courses/assignments';
import { getCourseThumbnailMediaDirectory } from '@services/media/media'; import { getCourseThumbnailMediaDirectory } from '@services/media/media';

View file

@ -1,10 +1,10 @@
'use client' '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 CreateCourseModal from '@components/Objects/Modals/Course/Create/CreateCourse'
import CourseThumbnail, { removeCoursePrefix } from '@components/Objects/Thumbnails/CourseThumbnail' import CourseThumbnail, { removeCoursePrefix } from '@components/Objects/Thumbnails/CourseThumbnail'
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement' import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton' import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton'
import Modal from '@components/StyledElements/Modal/Modal' import Modal from '@components/Objects/StyledElements/Modal/Modal'
import { useSearchParams } from 'next/navigation' import { useSearchParams } from 'next/navigation'
import React from 'react' import React from 'react'
import useAdminStatus from '@components/Hooks/useAdminStatus' import useAdminStatus from '@components/Hooks/useAdminStatus'

View file

@ -1,14 +1,14 @@
'use client' 'use client'
import EditCourseStructure from '../../../../../../../../components/Dashboard/Course/EditCourseStructure/EditCourseStructure'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import React from 'react' import React from 'react'
import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext' import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext'
import Link from 'next/link' 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 { motion } from 'framer-motion'
import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral'
import { GalleryVerticalEnd, Info, UserRoundCog } from 'lucide-react' 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 = { export type CourseOverviewParams = {
orgslug: string orgslug: string

View file

@ -1,11 +1,11 @@
'use client' 'use client'
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs' import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { Info } from 'lucide-react' import { Info } from 'lucide-react'
import Link from 'next/link' import Link from 'next/link'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { motion } from 'framer-motion' 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 = { export type OrgParams = {
subpage: string 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' 'use client'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
import { motion } from 'framer-motion' import { motion } from 'framer-motion'
import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral' import UserEditGeneral from '@components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral'
import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword' import UserEditPassword from '@components/Dashboard/Pages/UserAccount/UserEditPassword/UserEditPassword'
import Link from 'next/link' import Link from 'next/link'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { Info, Lock } from 'lucide-react' 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' import { useLHSession } from '@components/Contexts/LHSessionContext'
export type SettingsParams = { export type SettingsParams = {

View file

@ -5,13 +5,13 @@ import Link from 'next/link'
import { useMediaQuery } from 'usehooks-ts' import { useMediaQuery } from 'usehooks-ts'
import { getUriWithOrg } from '@services/config/config' import { getUriWithOrg } from '@services/config/config'
import { Monitor, ScanEye, SquareUserRound, UserPlus, Users } from 'lucide-react' 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 { useLHSession } from '@components/Contexts/LHSessionContext'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers' import OrgUsers from '@components/Dashboard/Pages/Users/OrgUsers/OrgUsers'
import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess' import OrgAccess from '@components/Dashboard/Pages/Users/OrgAccess/OrgAccess'
import OrgUsersAdd from '@components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd' import OrgUsersAdd from '@components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd'
import OrgUserGroups from '@components/Dashboard/Users/OrgUserGroups/OrgUserGroups' import OrgUserGroups from '@components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups'
export type SettingsParams = { export type SettingsParams = {
subpage: string 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' 'use client'
import { OrgProvider } from '@components/Contexts/OrgContext' import { OrgProvider } from '@components/Contexts/OrgContext'
import NextTopLoader from 'nextjs-toploader'; 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 '@styles/globals.css'
import Onboarding from '@components/Onboarding/Onboarding'; import Onboarding from '@components/Objects/Onboarding/Onboarding';
export default function RootLayout({ export default function RootLayout({
children, 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' 'use client'
import PageLoading from '@components/Objects/Loaders/PageLoading'; import PageLoading from '@components/Objects/Loaders/PageLoading';
import { useSession } from 'next-auth/react'; 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 export const SessionContext = createContext({}) as any
function LHSessionProvider({ children }: { children: React.ReactNode }) { function LHSessionProvider({ children }: { children: React.ReactNode }) {
const session = useSession(); const session = useSession();
useEffect(() => {
}, [])
if (session && session.status == 'loading') { if (session && session.status == 'loading') {
return <PageLoading /> return <PageLoading />
} }

View file

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

View file

@ -1,9 +1,9 @@
'use client' 'use client'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import { signOut } from 'next-auth/react' 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 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 Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import React, { useEffect } from 'react' import React, { useEffect } from 'react'
@ -11,11 +11,13 @@ import UserAvatar from '../../Objects/UserAvatar'
import AdminAuthorization from '@components/Security/AdminAuthorization' import AdminAuthorization from '@components/Security/AdminAuthorization'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config' import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
import useFeatureFlag from '@components/Hooks/useFeatureFlag'
function DashLeftMenu() { function DashLeftMenu() {
const org = useOrg() as any const org = useOrg() as any
const session = useLHSession() as any const session = useLHSession() as any
const [loading, setLoading] = React.useState(true) const [loading, setLoading] = React.useState(true)
const isPaymentsEnabled = useFeatureFlag({ path: ['features', 'payments', 'enabled'], defaultValue: false })
function waitForEverythingToLoad() { function waitForEverythingToLoad() {
if (org && session) { if (org && session) {
@ -112,6 +114,16 @@ function DashLeftMenu() {
<Users size={18} /> <Users size={18} />
</Link> </Link>
</ToolTip> </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 <ToolTip
content={'Organization'} content={'Organization'}
slateBlack slateBlack
@ -139,23 +151,41 @@ function DashLeftMenu() {
<UserAvatar border="border-4" width={35} /> <UserAvatar border="border-4" width={35} />
</div> </div>
</ToolTip> </ToolTip>
<div className="flex items-center flex-col space-y-1"> <div className="flex items-center flex-col space-y-3">
<ToolTip <div className="flex flex-col space-y-1 py-1">
content={session.data.user.username + "'s Settings"} <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 slateBlack
sideOffset={8} sideOffset={8}
side="right" side="right"
> >
<Link <Link
href={'/dash/user-account/settings/general'} href={'/dash/user-account/settings/general'}
className="py-3" className="py-1"
> >
<Settings <Settings
className="mx-auto text-neutral-400 cursor-pointer" className="mx-auto text-neutral-400 cursor-pointer"
size={18} size={18}
/> />
</Link> </Link>
</ToolTip> </ToolTip>
</div>
<ToolTip <ToolTip
content={'Logout'} content={'Logout'}
slateBlack slateBlack

View file

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

View file

@ -1,11 +1,11 @@
'use client'; 'use client';
import { useOrg } from '@components/Contexts/OrgContext'; 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 Link from 'next/link'
import React from 'react' import React from 'react'
type BreadCrumbsProps = { type BreadCrumbsProps = {
type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments' type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments' | 'payments'
last_breadcrumb?: string 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"> <div className="flex items-center space-x-1 first-letter:uppercase">
{props.last_breadcrumb ? <ChevronRight size={17} /> : ''} {props.last_breadcrumb ? <ChevronRight size={17} /> : ''}
<div className="first-letter:uppercase"> <div className="first-letter:uppercase">

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
import { useCourse } from '@components/Contexts/CourseContext' import { useCourse } from '@components/Contexts/CourseContext'
import NewActivityModal from '@components/Objects/Modals/Activities/Create/NewActivity' 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 { getAPIUrl } from '@services/config/config'
import { import {
createActivity, 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 { getAPIUrl, getUriWithOrg } from '@services/config/config'
import { deleteActivity, updateActivity } from '@services/courses/activities' import { deleteActivity, updateActivity } from '@services/courses/activities'
import { revalidateTags } from '@services/utils/ts/requests' import { revalidateTags } from '@services/utils/ts/requests'
@ -7,6 +7,7 @@ import {
Eye, Eye,
File, File,
FilePenLine, FilePenLine,
FileSymlink,
Globe, Globe,
Lock, Lock,
MoreVertical, MoreVertical,
@ -27,6 +28,7 @@ import { useOrg } from '@components/Contexts/OrgContext'
import { useCourse } from '@components/Contexts/CourseContext' import { useCourse } from '@components/Contexts/CourseContext'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { useMediaQuery } from 'usehooks-ts' import { useMediaQuery } from 'usehooks-ts'
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
type ActivitiyElementProps = { type ActivitiyElementProps = {
orgslug: string orgslug: string
@ -176,24 +178,26 @@ function ActivityElement(props: ActivitiyElementProps) {
)} )}
<span>{!props.activity.published ? 'Publish' : 'Unpublish'}</span> <span>{!props.activity.published ? 'Publish' : 'Unpublish'}</span>
</button> </button>
<Link <div className="w-px h-3 bg-gray-300 mx-1 self-center rounded-full hidden sm:block" />
href={ <ToolTip content="Preview Activity" sideOffset={8}>
getUriWithOrg(props.orgslug, '') + <Link
`/course/${props.course_uuid.replace( href={
'course_', getUriWithOrg(props.orgslug, '') +
'' `/course/${props.course_uuid.replace(
)}/activity/${props.activity.activity_uuid.replace( 'course_',
'activity_', ''
'' )}/activity/${props.activity.activity_uuid.replace(
)}` 'activity_',
} ''
prefetch )}`
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" prefetch
> 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"
<Eye strokeWidth={2} size={12} className="text-sky-600" /> rel="noopener noreferrer"
<span>Preview</span> >
</Link> <Eye strokeWidth={2} size={14} className="text-sky-600" />
</Link>
</ToolTip>
{/* Delete Button */} {/* Delete Button */}
<ConfirmationModal <ConfirmationModal
confirmationMessage="Are you sure you want to delete this activity ?" confirmationMessage="Are you sure you want to delete this activity ?"
@ -205,7 +209,6 @@ function ActivityElement(props: ActivitiyElementProps) {
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<X size={15} className="text-rose-200 font-bold" /> <X size={15} className="text-rose-200 font-bold" />
{!isMobile && <span className="text-rose-200 font-bold text-xs">Delete</span>}
</button> </button>
} }
functionToExecute={() => deleteActivityUI()} functionToExecute={() => deleteActivityUI()}

View file

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

View file

@ -13,7 +13,7 @@ import {
useCourseDispatch, useCourseDispatch,
} from '@components/Contexts/CourseContext' } from '@components/Contexts/CourseContext'
import { Hexagon } from 'lucide-react' 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 NewChapterModal from '@components/Objects/Modals/Chapters/NewChapter'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'

View file

@ -12,7 +12,7 @@ import { useRouter } from 'next/navigation'
import { useOrg } from '@components/Contexts/OrgContext' import { useOrg } from '@components/Contexts/OrgContext'
import { useLHSession } from '@components/Contexts/LHSessionContext' import { useLHSession } from '@components/Contexts/LHSessionContext'
import { getOrgLogoMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media' 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 { Toaster, toast } from 'react-hot-toast';
import { constructAcceptValue } from '@/lib/constants'; 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