mirror of
https://github.com/rzmk/learnhouse.git
synced 2025-12-19 04:19:25 +00:00
Merge pull request #316 from learnhouse/feat/payments
Payments & Subscriptions
This commit is contained in:
commit
60740c4166
176 changed files with 7282 additions and 1882 deletions
13
.github/workflows/docker-build.yaml
vendored
13
.github/workflows/docker-build.yaml
vendored
|
|
@ -1,6 +1,8 @@
|
|||
name: App Build
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- "**"
|
||||
pull_request:
|
||||
|
|
@ -14,3 +16,14 @@ jobs:
|
|||
- name: Build Docker Image
|
||||
run: docker build -t learnhouse .
|
||||
working-directory: .
|
||||
- name: Repository Dispatch
|
||||
uses: peter-evans/repository-dispatch@v2
|
||||
with:
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
repository: learnhouse/images
|
||||
event-type: build-images
|
||||
client-payload: |
|
||||
{
|
||||
"ref": "${{ github.sha }}",
|
||||
"branch": "${{ github.head_ref || github.ref_name }}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ def install(
|
|||
slug="default",
|
||||
email="",
|
||||
logo_image="",
|
||||
thumbnail_image="",
|
||||
)
|
||||
install_create_organization(org, db_session)
|
||||
print("Default organization created ✅")
|
||||
|
|
@ -89,6 +90,7 @@ def install(
|
|||
slug=slug.lower(),
|
||||
email="",
|
||||
logo_image="",
|
||||
thumbnail_image="",
|
||||
)
|
||||
install_create_organization(org, db_session)
|
||||
print(orgname + " Organization created ✅")
|
||||
|
|
|
|||
|
|
@ -71,6 +71,18 @@ class RedisConfig(BaseModel):
|
|||
redis_connection_string: Optional[str]
|
||||
|
||||
|
||||
class InternalStripeConfig(BaseModel):
|
||||
stripe_secret_key: str | None
|
||||
stripe_publishable_key: str | None
|
||||
stripe_webhook_standard_secret: str | None
|
||||
stripe_webhook_connect_secret: str | None
|
||||
stripe_client_id: str | None
|
||||
|
||||
|
||||
class InternalPaymentsConfig(BaseModel):
|
||||
stripe: InternalStripeConfig
|
||||
|
||||
|
||||
class LearnHouseConfig(BaseModel):
|
||||
site_name: str
|
||||
site_description: str
|
||||
|
|
@ -82,6 +94,7 @@ class LearnHouseConfig(BaseModel):
|
|||
security_config: SecurityConfig
|
||||
ai_config: AIConfig
|
||||
mailing_config: MailingConfig
|
||||
payments_config: InternalPaymentsConfig
|
||||
|
||||
|
||||
def get_learnhouse_config() -> LearnHouseConfig:
|
||||
|
|
@ -261,6 +274,33 @@ def get_learnhouse_config() -> LearnHouseConfig:
|
|||
else:
|
||||
sentry_config = None
|
||||
|
||||
# Payments config
|
||||
env_stripe_secret_key = os.environ.get("LEARNHOUSE_STRIPE_SECRET_KEY")
|
||||
env_stripe_publishable_key = os.environ.get("LEARNHOUSE_STRIPE_PUBLISHABLE_KEY")
|
||||
env_stripe_webhook_standard_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_STANDARD_SECRET")
|
||||
env_stripe_webhook_connect_secret = os.environ.get("LEARNHOUSE_STRIPE_WEBHOOK_CONNECT_SECRET")
|
||||
env_stripe_client_id = os.environ.get("LEARNHOUSE_STRIPE_CLIENT_ID")
|
||||
|
||||
stripe_secret_key = env_stripe_secret_key or yaml_config.get("payments_config", {}).get(
|
||||
"stripe", {}
|
||||
).get("stripe_secret_key")
|
||||
|
||||
stripe_publishable_key = env_stripe_publishable_key or yaml_config.get("payments_config", {}).get(
|
||||
"stripe", {}
|
||||
).get("stripe_publishable_key")
|
||||
|
||||
stripe_webhook_standard_secret = env_stripe_webhook_standard_secret or yaml_config.get("payments_config", {}).get(
|
||||
"stripe", {}
|
||||
).get("stripe_webhook_standard_secret")
|
||||
|
||||
stripe_webhook_connect_secret = env_stripe_webhook_connect_secret or yaml_config.get("payments_config", {}).get(
|
||||
"stripe", {}
|
||||
).get("stripe_webhook_connect_secret")
|
||||
|
||||
stripe_client_id = env_stripe_client_id or yaml_config.get("payments_config", {}).get(
|
||||
"stripe", {}
|
||||
).get("stripe_client_id")
|
||||
|
||||
# Create HostingConfig and DatabaseConfig objects
|
||||
hosting_config = HostingConfig(
|
||||
domain=domain,
|
||||
|
|
@ -303,6 +343,15 @@ def get_learnhouse_config() -> LearnHouseConfig:
|
|||
mailing_config=MailingConfig(
|
||||
resend_api_key=resend_api_key, system_email_address=system_email_address
|
||||
),
|
||||
payments_config=InternalPaymentsConfig(
|
||||
stripe=InternalStripeConfig(
|
||||
stripe_secret_key=stripe_secret_key,
|
||||
stripe_publishable_key=stripe_publishable_key,
|
||||
stripe_webhook_standard_secret=stripe_webhook_standard_secret,
|
||||
stripe_webhook_connect_secret=stripe_webhook_connect_secret,
|
||||
stripe_client_id=stripe_client_id
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return config
|
||||
|
|
|
|||
|
|
@ -37,6 +37,13 @@ database_config:
|
|||
redis_config:
|
||||
redis_connection_string: redis://localhost:6379/learnhouse
|
||||
|
||||
payments_config:
|
||||
stripe:
|
||||
stripe_secret_key: ""
|
||||
stripe_publishable_key: ""
|
||||
stripe_webhook_standard_secret: ""
|
||||
stripe_client_id: ""
|
||||
|
||||
ai_config:
|
||||
chromadb_config:
|
||||
isSeparateDatabaseEnabled: True
|
||||
|
|
|
|||
90
apps/api/migrations/versions/0314ec7791e1_payments.py
Normal file
90
apps/api/migrations/versions/0314ec7791e1_payments.py
Normal 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
361
apps/api/poetry.lock
generated
|
|
@ -1,4 +1,4 @@
|
|||
# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiohappyeyeballs"
|
||||
|
|
@ -276,17 +276,17 @@ typecheck = ["mypy"]
|
|||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.35.49"
|
||||
version = "1.35.52"
|
||||
description = "The AWS SDK for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "boto3-1.35.49-py3-none-any.whl", hash = "sha256:b660c649a27a6b47a34f6f858f5bd7c3b0a798a16dec8dda7cbebeee80fd1f60"},
|
||||
{file = "boto3-1.35.49.tar.gz", hash = "sha256:ddecb27f5699ca9f97711c52b6c0652c2e63bf6c2bfbc13b819b4f523b4d30ff"},
|
||||
{file = "boto3-1.35.52-py3-none-any.whl", hash = "sha256:ec0e797441db56af63b1150bba49f114b0f885f5d76c3b6dc18075f73030d2bb"},
|
||||
{file = "boto3-1.35.52.tar.gz", hash = "sha256:68299da8ab2bb37cc843d61b9f4c1c9367438406cfd65a8f593afc7b3bfe226d"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
botocore = ">=1.35.49,<1.36.0"
|
||||
botocore = ">=1.35.52,<1.36.0"
|
||||
jmespath = ">=0.7.1,<2.0.0"
|
||||
s3transfer = ">=0.10.0,<0.11.0"
|
||||
|
||||
|
|
@ -295,13 +295,13 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
|
|||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.35.49"
|
||||
version = "1.35.52"
|
||||
description = "Low-level, data-driven core of boto 3."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "botocore-1.35.49-py3-none-any.whl", hash = "sha256:aed4d3643afd702920792b68fbe712a8c3847993820d1048cd238a6469354da1"},
|
||||
{file = "botocore-1.35.49.tar.gz", hash = "sha256:07d0c1325fdbfa49a4a054413dbdeab0a6030449b2aa66099241af2dac48afd8"},
|
||||
{file = "botocore-1.35.52-py3-none-any.whl", hash = "sha256:cdbb5e43c9c3a977763e2a10d3b8b9c405d51279f9fcfd4ca4800763b22acba5"},
|
||||
{file = "botocore-1.35.52.tar.gz", hash = "sha256:1fe7485ea13d638b089103addd818c12984ff1e4d208de15f180b1e25ad944c5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -514,13 +514,13 @@ numpy = "*"
|
|||
|
||||
[[package]]
|
||||
name = "chromadb"
|
||||
version = "0.5.11"
|
||||
version = "0.5.16"
|
||||
description = "Chroma."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "chromadb-0.5.11-py3-none-any.whl", hash = "sha256:f02d9326869cea926f980bd6c9a0150a0ef2e151072f325998c16a9502fb4b25"},
|
||||
{file = "chromadb-0.5.11.tar.gz", hash = "sha256:252e970b3e1a27b594cc7b3685238691bf8eaa232225d4dee9e33ec83580775f"},
|
||||
{file = "chromadb-0.5.16-py3-none-any.whl", hash = "sha256:ae96f1c81fa691a163a2d625dc769c5c1afa3219d1ac26796fbf9d60d7924d71"},
|
||||
{file = "chromadb-0.5.16.tar.gz", hash = "sha256:ab947065125908b228cc343e7d9f21bcea5036dcd237d993caa66e5fc262dd9e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -1053,70 +1053,70 @@ test = ["objgraph", "psutil"]
|
|||
|
||||
[[package]]
|
||||
name = "grpcio"
|
||||
version = "1.67.0"
|
||||
version = "1.67.1"
|
||||
description = "HTTP/2-based RPC framework"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "grpcio-1.67.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:bd79929b3bb96b54df1296cd3bf4d2b770bd1df6c2bdf549b49bab286b925cdc"},
|
||||
{file = "grpcio-1.67.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:16724ffc956ea42967f5758c2f043faef43cb7e48a51948ab593570570d1e68b"},
|
||||
{file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:2b7183c80b602b0ad816315d66f2fb7887614ead950416d60913a9a71c12560d"},
|
||||
{file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:efe32b45dd6d118f5ea2e5deaed417d8a14976325c93812dd831908522b402c9"},
|
||||
{file = "grpcio-1.67.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe89295219b9c9e47780a0f1c75ca44211e706d1c598242249fe717af3385ec8"},
|
||||
{file = "grpcio-1.67.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa8d025fae1595a207b4e47c2e087cb88d47008494db258ac561c00877d4c8f8"},
|
||||
{file = "grpcio-1.67.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f95e15db43e75a534420e04822df91f645664bf4ad21dfaad7d51773c80e6bb4"},
|
||||
{file = "grpcio-1.67.0-cp310-cp310-win32.whl", hash = "sha256:a6b9a5c18863fd4b6624a42e2712103fb0f57799a3b29651c0e5b8119a519d65"},
|
||||
{file = "grpcio-1.67.0-cp310-cp310-win_amd64.whl", hash = "sha256:b6eb68493a05d38b426604e1dc93bfc0137c4157f7ab4fac5771fd9a104bbaa6"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:e91d154689639932305b6ea6f45c6e46bb51ecc8ea77c10ef25aa77f75443ad4"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cb204a742997277da678611a809a8409657b1398aaeebf73b3d9563b7d154c13"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:ae6de510f670137e755eb2a74b04d1041e7210af2444103c8c95f193340d17ee"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74b900566bdf68241118f2918d312d3bf554b2ce0b12b90178091ea7d0a17b3d"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4e95e43447a02aa603abcc6b5e727d093d161a869c83b073f50b9390ecf0fa8"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0bb94e66cd8f0baf29bd3184b6aa09aeb1a660f9ec3d85da615c5003154bc2bf"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:82e5bd4b67b17c8c597273663794a6a46a45e44165b960517fe6d8a2f7f16d23"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-win32.whl", hash = "sha256:7fc1d2b9fd549264ae585026b266ac2db53735510a207381be509c315b4af4e8"},
|
||||
{file = "grpcio-1.67.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac11ecb34a86b831239cc38245403a8de25037b448464f95c3315819e7519772"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:227316b5631260e0bef8a3ce04fa7db4cc81756fea1258b007950b6efc90c05d"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:d90cfdafcf4b45a7a076e3e2a58e7bc3d59c698c4f6470b0bb13a4d869cf2273"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:77196216d5dd6f99af1c51e235af2dd339159f657280e65ce7e12c1a8feffd1d"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15c05a26a0f7047f720da41dc49406b395c1470eef44ff7e2c506a47ac2c0591"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3840994689cc8cbb73d60485c594424ad8adb56c71a30d8948d6453083624b52"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:5a1e03c3102b6451028d5dc9f8591131d6ab3c8a0e023d94c28cb930ed4b5f81"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:682968427a63d898759474e3b3178d42546e878fdce034fd7474ef75143b64e3"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-win32.whl", hash = "sha256:d01793653248f49cf47e5695e0a79805b1d9d4eacef85b310118ba1dfcd1b955"},
|
||||
{file = "grpcio-1.67.0-cp312-cp312-win_amd64.whl", hash = "sha256:985b2686f786f3e20326c4367eebdaed3e7aa65848260ff0c6644f817042cb15"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:8c9a35b8bc50db35ab8e3e02a4f2a35cfba46c8705c3911c34ce343bd777813a"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:42199e704095b62688998c2d84c89e59a26a7d5d32eed86d43dc90e7a3bd04aa"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:c4c425f440fb81f8d0237c07b9322fc0fb6ee2b29fbef5f62a322ff8fcce240d"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:323741b6699cd2b04a71cb38f502db98f90532e8a40cb675393d248126a268af"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:662c8e105c5e5cee0317d500eb186ed7a93229586e431c1bf0c9236c2407352c"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f6bd2ab135c64a4d1e9e44679a616c9bc944547357c830fafea5c3caa3de5153"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:2f55c1e0e2ae9bdd23b3c63459ee4c06d223b68aeb1961d83c48fb63dc29bc03"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-win32.whl", hash = "sha256:fd6bc27861e460fe28e94226e3673d46e294ca4673d46b224428d197c5935e69"},
|
||||
{file = "grpcio-1.67.0-cp313-cp313-win_amd64.whl", hash = "sha256:cf51d28063338608cd8d3cd64677e922134837902b70ce00dad7f116e3998210"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:7f200aca719c1c5dc72ab68be3479b9dafccdf03df530d137632c534bb6f1ee3"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0892dd200ece4822d72dd0952f7112c542a487fc48fe77568deaaa399c1e717d"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:f4d613fbf868b2e2444f490d18af472ccb47660ea3df52f068c9c8801e1f3e85"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c69bf11894cad9da00047f46584d5758d6ebc9b5950c0dc96fec7e0bce5cde9"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9bca3ca0c5e74dea44bf57d27e15a3a3996ce7e5780d61b7c72386356d231db"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:014dfc020e28a0d9be7e93a91f85ff9f4a87158b7df9952fe23cc42d29d31e1e"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d4ea4509d42c6797539e9ec7496c15473177ce9abc89bc5c71e7abe50fc25737"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-win32.whl", hash = "sha256:9d75641a2fca9ae1ae86454fd25d4c298ea8cc195dbc962852234d54a07060ad"},
|
||||
{file = "grpcio-1.67.0-cp38-cp38-win_amd64.whl", hash = "sha256:cff8e54d6a463883cda2fab94d2062aad2f5edd7f06ae3ed030f2a74756db365"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:62492bd534979e6d7127b8a6b29093161a742dee3875873e01964049d5250a74"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eef1dce9d1a46119fd09f9a992cf6ab9d9178b696382439446ca5f399d7b96fe"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:f623c57a5321461c84498a99dddf9d13dac0e40ee056d884d6ec4ebcab647a78"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:54d16383044e681f8beb50f905249e4e7261dd169d4aaf6e52eab67b01cbbbe2"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2a44e572fb762c668e4812156b81835f7aba8a721b027e2d4bb29fb50ff4d33"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:391df8b0faac84d42f5b8dfc65f5152c48ed914e13c522fd05f2aca211f8bfad"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:cfd9306511fdfc623a1ba1dc3bc07fbd24e6cfbe3c28b4d1e05177baa2f99617"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-win32.whl", hash = "sha256:30d47dbacfd20cbd0c8be9bfa52fdb833b395d4ec32fe5cff7220afc05d08571"},
|
||||
{file = "grpcio-1.67.0-cp39-cp39-win_amd64.whl", hash = "sha256:f55f077685f61f0fbd06ea355142b71e47e4a26d2d678b3ba27248abfe67163a"},
|
||||
{file = "grpcio-1.67.0.tar.gz", hash = "sha256:e090b2553e0da1c875449c8e75073dd4415dd71c9bde6a406240fdf4c0ee467c"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:8b0341d66a57f8a3119b77ab32207072be60c9bf79760fa609c5609f2deb1f3f"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:f5a27dddefe0e2357d3e617b9079b4bfdc91341a91565111a21ed6ebbc51b22d"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:43112046864317498a33bdc4797ae6a268c36345a910de9b9c17159d8346602f"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9b929f13677b10f63124c1a410994a401cdd85214ad83ab67cc077fc7e480f0"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7d1797a8a3845437d327145959a2c0c47c05947c9eef5ff1a4c80e499dcc6fa"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:0489063974d1452436139501bf6b180f63d4977223ee87488fe36858c5725292"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9fd042de4a82e3e7aca44008ee2fb5da01b3e5adb316348c21980f7f58adc311"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-win32.whl", hash = "sha256:638354e698fd0c6c76b04540a850bf1db27b4d2515a19fcd5cf645c48d3eb1ed"},
|
||||
{file = "grpcio-1.67.1-cp310-cp310-win_amd64.whl", hash = "sha256:608d87d1bdabf9e2868b12338cd38a79969eaf920c89d698ead08f48de9c0f9e"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:7818c0454027ae3384235a65210bbf5464bd715450e30a3d40385453a85a70cb"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ea33986b70f83844cd00814cee4451055cd8cab36f00ac64a31f5bb09b31919e"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:c7a01337407dd89005527623a4a72c5c8e2894d22bead0895306b23c6695698f"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80b866f73224b0634f4312a4674c1be21b2b4afa73cb20953cbbb73a6b36c3cc"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fff78ba10d4250bfc07a01bd6254a6d87dc67f9627adece85c0b2ed754fa96"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:8a23cbcc5bb11ea7dc6163078be36c065db68d915c24f5faa4f872c573bb400f"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1a65b503d008f066e994f34f456e0647e5ceb34cfcec5ad180b1b44020ad4970"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-win32.whl", hash = "sha256:e29ca27bec8e163dca0c98084040edec3bc49afd10f18b412f483cc68c712744"},
|
||||
{file = "grpcio-1.67.1-cp311-cp311-win_amd64.whl", hash = "sha256:786a5b18544622bfb1e25cc08402bd44ea83edfb04b93798d85dca4d1a0b5be5"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:267d1745894200e4c604958da5f856da6293f063327cb049a51fe67348e4f953"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:85f69fdc1d28ce7cff8de3f9c67db2b0ca9ba4449644488c1e0303c146135ddb"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f26b0b547eb8d00e195274cdfc63ce64c8fc2d3e2d00b12bf468ece41a0423a0"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4422581cdc628f77302270ff839a44f4c24fdc57887dc2a45b7e53d8fc2376af"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1d7616d2ded471231c701489190379e0c311ee0a6c756f3c03e6a62b95a7146e"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8a00efecde9d6fcc3ab00c13f816313c040a28450e5e25739c24f432fc6d3c75"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:699e964923b70f3101393710793289e42845791ea07565654ada0969522d0a38"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-win32.whl", hash = "sha256:4e7b904484a634a0fff132958dabdb10d63e0927398273917da3ee103e8d1f78"},
|
||||
{file = "grpcio-1.67.1-cp312-cp312-win_amd64.whl", hash = "sha256:5721e66a594a6c4204458004852719b38f3d5522082be9061d6510b455c90afc"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:aa0162e56fd10a5547fac8774c4899fc3e18c1aa4a4759d0ce2cd00d3696ea6b"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:beee96c8c0b1a75d556fe57b92b58b4347c77a65781ee2ac749d550f2a365dc1"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:a93deda571a1bf94ec1f6fcda2872dad3ae538700d94dc283c672a3b508ba3af"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e6f255980afef598a9e64a24efce87b625e3e3c80a45162d111a461a9f92955"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e838cad2176ebd5d4a8bb03955138d6589ce9e2ce5d51c3ada34396dbd2dba8"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:a6703916c43b1d468d0756c8077b12017a9fcb6a1ef13faf49e67d20d7ebda62"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:917e8d8994eed1d86b907ba2a61b9f0aef27a2155bca6cbb322430fc7135b7bb"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-win32.whl", hash = "sha256:e279330bef1744040db8fc432becc8a727b84f456ab62b744d3fdb83f327e121"},
|
||||
{file = "grpcio-1.67.1-cp313-cp313-win_amd64.whl", hash = "sha256:fa0c739ad8b1996bd24823950e3cb5152ae91fca1c09cc791190bf1627ffefba"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:178f5db771c4f9a9facb2ab37a434c46cb9be1a75e820f187ee3d1e7805c4f65"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f3e49c738396e93b7ba9016e153eb09e0778e776df6090c1b8c91877cc1c426"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:24e8a26dbfc5274d7474c27759b54486b8de23c709d76695237515bc8b5baeab"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b6c16489326d79ead41689c4b84bc40d522c9a7617219f4ad94bc7f448c5085"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:60e6a4dcf5af7bbc36fd9f81c9f372e8ae580870a9e4b6eafe948cd334b81cf3"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:95b5f2b857856ed78d72da93cd7d09b6db8ef30102e5e7fe0961fe4d9f7d48e8"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b49359977c6ec9f5d0573ea4e0071ad278ef905aa74e420acc73fd28ce39e9ce"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-win32.whl", hash = "sha256:f5b76ff64aaac53fede0cc93abf57894ab2a7362986ba22243d06218b93efe46"},
|
||||
{file = "grpcio-1.67.1-cp38-cp38-win_amd64.whl", hash = "sha256:804c6457c3cd3ec04fe6006c739579b8d35c86ae3298ffca8de57b493524b771"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:a25bdea92b13ff4d7790962190bf6bf5c4639876e01c0f3dda70fc2769616335"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cdc491ae35a13535fd9196acb5afe1af37c8237df2e54427be3eecda3653127e"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:85f862069b86a305497e74d0dc43c02de3d1d184fc2c180993aa8aa86fbd19b8"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec74ef02010186185de82cc594058a3ccd8d86821842bbac9873fd4a2cf8be8d"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01f616a964e540638af5130469451cf580ba8c7329f45ca998ab66e0c7dcdb04"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:299b3d8c4f790c6bcca485f9963b4846dd92cf6f1b65d3697145d005c80f9fe8"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:60336bff760fbb47d7e86165408126f1dded184448e9a4c892189eb7c9d3f90f"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-win32.whl", hash = "sha256:5ed601c4c6008429e3d247ddb367fe8c7259c355757448d7c1ef7bd4a6739e8e"},
|
||||
{file = "grpcio-1.67.1-cp39-cp39-win_amd64.whl", hash = "sha256:5db70d32d6703b89912af16d6d45d78406374a8b8ef0d28140351dd0ec610e98"},
|
||||
{file = "grpcio-1.67.1.tar.gz", hash = "sha256:3dc2ed4cabea4dc14d5e708c2b426205956077cc5de419b4d4079315017e9732"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
protobuf = ["grpcio-tools (>=1.67.0)"]
|
||||
protobuf = ["grpcio-tools (>=1.67.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
|
|
@ -1232,13 +1232,13 @@ zstd = ["zstandard (>=0.18.0)"]
|
|||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "0.26.1"
|
||||
version = "0.26.2"
|
||||
description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub"
|
||||
optional = false
|
||||
python-versions = ">=3.8.0"
|
||||
files = [
|
||||
{file = "huggingface_hub-0.26.1-py3-none-any.whl", hash = "sha256:5927a8fc64ae68859cd954b7cc29d1c8390a5e15caba6d3d349c973be8fdacf3"},
|
||||
{file = "huggingface_hub-0.26.1.tar.gz", hash = "sha256:414c0d9b769eecc86c70f9d939d0f48bb28e8461dd1130021542eff0212db890"},
|
||||
{file = "huggingface_hub-0.26.2-py3-none-any.whl", hash = "sha256:98c2a5a8e786c7b2cb6fdeb2740893cba4d53e312572ed3d8afafda65b128c46"},
|
||||
{file = "huggingface_hub-0.26.2.tar.gz", hash = "sha256:b100d853465d965733964d123939ba287da60a547087783ddff8a323f340332b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -1599,13 +1599,13 @@ tenacity = ">=8.1.0,<9.0.0"
|
|||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "0.2.41"
|
||||
version = "0.2.42"
|
||||
description = "Building applications with LLMs through composability"
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.8.1"
|
||||
files = [
|
||||
{file = "langchain_core-0.2.41-py3-none-any.whl", hash = "sha256:3278fda5ba9a05defae8bb19f1226032add6aab21917db7b3bc74e750e263e84"},
|
||||
{file = "langchain_core-0.2.41.tar.gz", hash = "sha256:bc12032c5a298d85be754ccb129bc13ea21ccb1d6e22f8d7ba18b8da64315bb5"},
|
||||
{file = "langchain_core-0.2.42-py3-none-any.whl", hash = "sha256:09503fdfb9efa163e51f2d9762894fde04797d0a41462c0e6072ef78028e48fd"},
|
||||
{file = "langchain_core-0.2.42.tar.gz", hash = "sha256:e4ea04b22bd6398048d0ef97cd3132fbdd80e6c749863ee96e6b7c88502ff913"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -1698,13 +1698,13 @@ requests = ">=2,<3"
|
|||
|
||||
[[package]]
|
||||
name = "langsmith"
|
||||
version = "0.1.137"
|
||||
version = "0.1.138"
|
||||
description = "Client library to connect to the LangSmith LLM Tracing and Evaluation Platform."
|
||||
optional = false
|
||||
python-versions = "<4.0,>=3.8.1"
|
||||
files = [
|
||||
{file = "langsmith-0.1.137-py3-none-any.whl", hash = "sha256:4256d5c61133749890f7b5c88321dbb133ce0f440c621ea28e76513285859b81"},
|
||||
{file = "langsmith-0.1.137.tar.gz", hash = "sha256:56cdfcc6c74cb20a3f437d5bd144feb5bf93f54c5a2918d1e568cbd084a372d4"},
|
||||
{file = "langsmith-0.1.138-py3-none-any.whl", hash = "sha256:5c2bd5c11c75f7b3d06a0f06b115186e7326ca969fd26d66ffc65a0669012aee"},
|
||||
{file = "langsmith-0.1.138.tar.gz", hash = "sha256:1ecf613bb52f6bf17f1510e24ad8b70d4b0259bc9d3dbfd69b648c66d4644f0b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -2215,13 +2215,13 @@ sympy = "*"
|
|||
|
||||
[[package]]
|
||||
name = "openai"
|
||||
version = "1.52.2"
|
||||
version = "1.53.0"
|
||||
description = "The official Python library for the openai API"
|
||||
optional = false
|
||||
python-versions = ">=3.7.1"
|
||||
files = [
|
||||
{file = "openai-1.52.2-py3-none-any.whl", hash = "sha256:57e9e37bc407f39bb6ec3a27d7e8fb9728b2779936daa1fcf95df17d3edfaccc"},
|
||||
{file = "openai-1.52.2.tar.gz", hash = "sha256:87b7d0f69d85f5641678d414b7ee3082363647a5c66a462ed7f3ccb59582da0d"},
|
||||
{file = "openai-1.53.0-py3-none-any.whl", hash = "sha256:20f408c32fc5cb66e60c6882c994cdca580a5648e10045cd840734194f033418"},
|
||||
{file = "openai-1.53.0.tar.gz", hash = "sha256:be2c4e77721b166cce8130e544178b7d579f751b4b074ffbaade3854b6f85ec5"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -3332,23 +3332,23 @@ tornado = ["tornado (>=6)"]
|
|||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "75.2.0"
|
||||
version = "75.3.0"
|
||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "setuptools-75.2.0-py3-none-any.whl", hash = "sha256:a7fcb66f68b4d9e8e66b42f9876150a3371558f98fa32222ffaa5bced76406f8"},
|
||||
{file = "setuptools-75.2.0.tar.gz", hash = "sha256:753bb6ebf1f465a1912e19ed1d41f403a79173a9acf66a42e7e6aec45c3c16ec"},
|
||||
{file = "setuptools-75.3.0-py3-none-any.whl", hash = "sha256:f2504966861356aa38616760c0f66568e535562374995367b4e69c7143cf6bcd"},
|
||||
{file = "setuptools-75.3.0.tar.gz", hash = "sha256:fba5dd4d766e97be1b1681d98712680ae8f2f26d7881245f2ce9e40714f1a686"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)", "ruff (>=0.5.2)"]
|
||||
core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=2.6.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
|
||||
core = ["importlib-metadata (>=6)", "importlib-resources (>=5.10.2)", "jaraco.collections", "jaraco.functools", "jaraco.text (>=3.7)", "more-itertools", "more-itertools (>=8.8)", "packaging", "packaging (>=24)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1)", "wheel (>=0.43.0)"]
|
||||
cover = ["pytest-cov"]
|
||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"]
|
||||
enabler = ["pytest-enabler (>=2.2)"]
|
||||
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
|
||||
type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.11.*)", "pytest-mypy"]
|
||||
test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "jaraco.test (>=5.5)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"]
|
||||
type = ["importlib-metadata (>=7.0.2)", "jaraco.develop (>=7.21)", "mypy (==1.12.*)", "pytest-mypy"]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
|
|
@ -3538,6 +3538,21 @@ anyio = ">=3.4.0,<5"
|
|||
[package.extras]
|
||||
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "stripe"
|
||||
version = "11.2.0"
|
||||
description = "Python bindings for the Stripe API"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "stripe-11.2.0-py2.py3-none-any.whl", hash = "sha256:dec812eabc95488862be40e6c799acdaf2e1225d686490a793f949fab745fdd0"},
|
||||
{file = "stripe-11.2.0.tar.gz", hash = "sha256:4c53d61d7b596070324bfa5d7215843145fe5466e48973d828aab41ad209b5ce"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
requests = {version = ">=2.20", markers = "python_version >= \"3.0\""}
|
||||
typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""}
|
||||
|
||||
[[package]]
|
||||
name = "sympy"
|
||||
version = "1.13.3"
|
||||
|
|
@ -3741,13 +3756,13 @@ testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"]
|
|||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.66.5"
|
||||
version = "4.66.6"
|
||||
description = "Fast, Extensible Progress Meter"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "tqdm-4.66.5-py3-none-any.whl", hash = "sha256:90279a3770753eafc9194a0364852159802111925aa30eb3f9d85b0e805ac7cd"},
|
||||
{file = "tqdm-4.66.5.tar.gz", hash = "sha256:e1020aef2e5096702d8a025ac7d16b1577279c9d63f8375b63083e9a5f0fcbad"},
|
||||
{file = "tqdm-4.66.6-py3-none-any.whl", hash = "sha256:223e8b5359c2efc4b30555531f09e9f2f3589bcd7fdd389271191031b49b7a63"},
|
||||
{file = "tqdm-4.66.6.tar.gz", hash = "sha256:4bdd694238bef1485ce839d67967ab50af8f9272aab687c0d7702a01da0be090"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -4182,93 +4197,93 @@ files = [
|
|||
|
||||
[[package]]
|
||||
name = "yarl"
|
||||
version = "1.16.0"
|
||||
version = "1.17.1"
|
||||
description = "Yet another URL library"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
files = [
|
||||
{file = "yarl-1.16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32468f41242d72b87ab793a86d92f885355bcf35b3355aa650bfa846a5c60058"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:234f3a3032b505b90e65b5bc6652c2329ea7ea8855d8de61e1642b74b4ee65d2"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a0296040e5cddf074c7f5af4a60f3fc42c0237440df7bcf5183be5f6c802ed5"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de6c14dd7c7c0badba48157474ea1f03ebee991530ba742d381b28d4f314d6f3"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b140e532fe0266003c936d017c1ac301e72ee4a3fd51784574c05f53718a55d8"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:019f5d58093402aa8f6661e60fd82a28746ad6d156f6c5336a70a39bd7b162b9"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c42998fd1cbeb53cd985bff0e4bc25fbe55fd6eb3a545a724c1012d69d5ec84"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c7c30fb38c300fe8140df30a046a01769105e4cf4282567a29b5cdb635b66c4"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e49e0fd86c295e743fd5be69b8b0712f70a686bc79a16e5268386c2defacaade"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:b9ca7b9147eb1365c8bab03c003baa1300599575effad765e0b07dd3501ea9af"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27e11db3f1e6a51081a981509f75617b09810529de508a181319193d320bc5c7"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8994c42f4ca25df5380ddf59f315c518c81df6a68fed5bb0c159c6cb6b92f120"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:542fa8e09a581bcdcbb30607c7224beff3fdfb598c798ccd28a8184ffc18b7eb"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2bd6a51010c7284d191b79d3b56e51a87d8e1c03b0902362945f15c3d50ed46b"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-win32.whl", hash = "sha256:178ccb856e265174a79f59721031060f885aca428983e75c06f78aa24b91d929"},
|
||||
{file = "yarl-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe8bba2545427418efc1929c5c42852bdb4143eb8d0a46b09de88d1fe99258e7"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d8643975a0080f361639787415a038bfc32d29208a4bf6b783ab3075a20b1ef3"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:676d96bafc8c2d0039cea0cd3fd44cee7aa88b8185551a2bb93354668e8315c2"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9525f03269e64310416dbe6c68d3b23e5d34aaa8f47193a1c45ac568cecbc49"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b37d5ec034e668b22cf0ce1074d6c21fd2a08b90d11b1b73139b750a8b0dd97"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f32c4cb7386b41936894685f6e093c8dfaf0960124d91fe0ec29fe439e201d0"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b8e265a0545637492a7e12fd7038370d66c9375a61d88c5567d0e044ded9202"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:789a3423f28a5fff46fbd04e339863c169ece97c827b44de16e1a7a42bc915d2"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1d1f45e3e8d37c804dca99ab3cf4ab3ed2e7a62cd82542924b14c0a4f46d243"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:621280719c4c5dad4c1391160a9b88925bb8b0ff6a7d5af3224643024871675f"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ed097b26f18a1f5ff05f661dc36528c5f6735ba4ce8c9645e83b064665131349"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2f1fe2b2e3ee418862f5ebc0c0083c97f6f6625781382f828f6d4e9b614eba9b"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:87dd10bc0618991c66cee0cc65fa74a45f4ecb13bceec3c62d78ad2e42b27a16"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4199db024b58a8abb2cfcedac7b1292c3ad421684571aeb622a02f242280e8d6"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:99a9dcd4b71dd5f5f949737ab3f356cfc058c709b4f49833aeffedc2652dac56"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-win32.whl", hash = "sha256:a9394c65ae0ed95679717d391c862dece9afacd8fa311683fc8b4362ce8a410c"},
|
||||
{file = "yarl-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5b9101f528ae0f8f65ac9d64dda2bb0627de8a50344b2f582779f32fda747c1d"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4ffb7c129707dd76ced0a4a4128ff452cecf0b0e929f2668ea05a371d9e5c104"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1a5e9d8ce1185723419c487758d81ac2bde693711947032cce600ca7c9cda7d6"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d743e3118b2640cef7768ea955378c3536482d95550222f908f392167fe62059"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26768342f256e6e3c37533bf9433f5f15f3e59e3c14b2409098291b3efaceacb"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1b0796168b953bca6600c5f97f5ed407479889a36ad7d17183366260f29a6b9"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:858728086914f3a407aa7979cab743bbda1fe2bdf39ffcd991469a370dd7414d"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5570e6d47bcb03215baf4c9ad7bf7c013e56285d9d35013541f9ac2b372593e7"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66ea8311422a7ba1fc79b4c42c2baa10566469fe5a78500d4e7754d6e6db8724"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:649bddcedee692ee8a9b7b6e38582cb4062dc4253de9711568e5620d8707c2a3"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a91654adb7643cb21b46f04244c5a315a440dcad63213033826549fa2435f71"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b439cae82034ade094526a8f692b9a2b5ee936452de5e4c5f0f6c48df23f8604"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:571f781ae8ac463ce30bacebfaef2c6581543776d5970b2372fbe31d7bf31a07"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:aa7943f04f36d6cafc0cf53ea89824ac2c37acbdb4b316a654176ab8ffd0f968"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a5cf32539373ff39d97723e39a9283a7277cbf1224f7aef0c56c9598b6486c3"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-win32.whl", hash = "sha256:a5b6c09b9b4253d6a208b0f4a2f9206e511ec68dce9198e0fbec4f160137aa67"},
|
||||
{file = "yarl-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1208ca14eed2fda324042adf8d6c0adf4a31522fa95e0929027cd487875f0240"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5ace0177520bd4caa99295a9b6fb831d0e9a57d8e0501a22ffaa61b4c024283"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7118bdb5e3ed81acaa2095cba7ec02a0fe74b52a16ab9f9ac8e28e53ee299732"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38fec8a2a94c58bd47c9a50a45d321ab2285ad133adefbbadf3012c054b7e656"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8791d66d81ee45866a7bb15a517b01a2bcf583a18ebf5d72a84e6064c417e64b"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cf936ba67bc6c734f3aa1c01391da74ab7fc046a9f8bbfa230b8393b90cf472"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1aab176dd55b59f77a63b27cffaca67d29987d91a5b615cbead41331e6b7428"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:995d0759004c08abd5d1b81300a91d18c8577c6389300bed1c7c11675105a44d"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bc22e00edeb068f71967ab99081e9406cd56dbed864fc3a8259442999d71552"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:35b4f7842154176523e0a63c9b871168c69b98065d05a4f637fce342a6a2693a"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7ace71c4b7a0c41f317ae24be62bb61e9d80838d38acb20e70697c625e71f120"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8f639e3f5795a6568aa4f7d2ac6057c757dcd187593679f035adbf12b892bb00"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e8be3aff14f0120ad049121322b107f8a759be76a6a62138322d4c8a337a9e2c"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:122d8e7986043d0549e9eb23c7fd23be078be4b70c9eb42a20052b3d3149c6f2"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0fd9c227990f609c165f56b46107d0bc34553fe0387818c42c02f77974402c36"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-win32.whl", hash = "sha256:595ca5e943baed31d56b33b34736461a371c6ea0038d3baec399949dd628560b"},
|
||||
{file = "yarl-1.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:921b81b8d78f0e60242fb3db615ea3f368827a76af095d5a69f1c3366db3f596"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab2b2ac232110a1fdb0d3ffcd087783edd3d4a6ced432a1bf75caf7b7be70916"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7f8713717a09acbfee7c47bfc5777e685539fefdd34fa72faf504c8be2f3df4e"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cdcffe1dbcb4477d2b4202f63cd972d5baa155ff5a3d9e35801c46a415b7f71a"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9a91217208306d82357c67daeef5162a41a28c8352dab7e16daa82e3718852a7"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ab3ed42c78275477ea8e917491365e9a9b69bb615cb46169020bd0aa5e2d6d3"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:707ae579ccb3262dfaef093e202b4c3fb23c3810e8df544b1111bd2401fd7b09"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad7a852d1cd0b8d8b37fc9d7f8581152add917a98cfe2ea6e241878795f917ae"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3f1cc3d3d4dc574bebc9b387f6875e228ace5748a7c24f49d8f01ac1bc6c31b"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5ff96da263740779b0893d02b718293cc03400c3a208fc8d8cd79d9b0993e532"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:3d375a19ba2bfe320b6d873f3fb165313b002cef8b7cc0a368ad8b8a57453837"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:62c7da0ad93a07da048b500514ca47b759459ec41924143e2ddb5d7e20fd3db5"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:147b0fcd0ee33b4b5f6edfea80452d80e419e51b9a3f7a96ce98eaee145c1581"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:504e1fe1cc4f170195320eb033d2b0ccf5c6114ce5bf2f617535c01699479bca"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:bdcf667a5dec12a48f669e485d70c54189f0639c2157b538a4cffd24a853624f"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-win32.whl", hash = "sha256:e9951afe6557c75a71045148890052cb942689ee4c9ec29f5436240e1fcc73b7"},
|
||||
{file = "yarl-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:7d7aaa8ff95d0840e289423e7dc35696c2b058d635f945bf05b5cd633146b027"},
|
||||
{file = "yarl-1.16.0-py3-none-any.whl", hash = "sha256:e6980a558d8461230c457218bd6c92dfc1d10205548215c2c21d79dc8d0a96f3"},
|
||||
{file = "yarl-1.16.0.tar.gz", hash = "sha256:b6f687ced5510a9a2474bbae96a4352e5ace5fa34dc44a217b0537fec1db00b4"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:0b1794853124e2f663f0ea54efb0340b457f08d40a1cef78edfa086576179c91"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fbea1751729afe607d84acfd01efd95e3b31db148a181a441984ce9b3d3469da"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8ee427208c675f1b6e344a1f89376a9613fc30b52646a04ac0c1f6587c7e46ec"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b74ff4767d3ef47ffe0cd1d89379dc4d828d4873e5528976ced3b44fe5b0a21"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:62a91aefff3d11bf60e5956d340eb507a983a7ec802b19072bb989ce120cd948"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:846dd2e1243407133d3195d2d7e4ceefcaa5f5bf7278f0a9bda00967e6326b04"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e844be8d536afa129366d9af76ed7cb8dfefec99f5f1c9e4f8ae542279a6dc3"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc7c92c1baa629cb03ecb0c3d12564f172218fb1739f54bf5f3881844daadc6d"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ae3476e934b9d714aa8000d2e4c01eb2590eee10b9d8cd03e7983ad65dfbfcba"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c7e177c619342e407415d4f35dec63d2d134d951e24b5166afcdfd1362828e17"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64cc6e97f14cf8a275d79c5002281f3040c12e2e4220623b5759ea7f9868d6a5"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:84c063af19ef5130084db70ada40ce63a84f6c1ef4d3dbc34e5e8c4febb20822"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:482c122b72e3c5ec98f11457aeb436ae4aecca75de19b3d1de7cf88bc40db82f"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:380e6c38ef692b8fd5a0f6d1fa8774d81ebc08cfbd624b1bca62a4d4af2f9931"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-win32.whl", hash = "sha256:16bca6678a83657dd48df84b51bd56a6c6bd401853aef6d09dc2506a78484c7b"},
|
||||
{file = "yarl-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:561c87fea99545ef7d692403c110b2f99dced6dff93056d6e04384ad3bc46243"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:cbad927ea8ed814622305d842c93412cb47bd39a496ed0f96bfd42b922b4a217"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fca4b4307ebe9c3ec77a084da3a9d1999d164693d16492ca2b64594340999988"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff5c6771c7e3511a06555afa317879b7db8d640137ba55d6ab0d0c50425cab75"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b29beab10211a746f9846baa39275e80034e065460d99eb51e45c9a9495bcca"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a52a1ffdd824fb1835272e125385c32fd8b17fbdefeedcb4d543cc23b332d74"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:58c8e9620eb82a189c6c40cb6b59b4e35b2ee68b1f2afa6597732a2b467d7e8f"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d216e5d9b8749563c7f2c6f7a0831057ec844c68b4c11cb10fc62d4fd373c26d"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:881764d610e3269964fc4bb3c19bb6fce55422828e152b885609ec176b41cf11"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8c79e9d7e3d8a32d4824250a9c6401194fb4c2ad9a0cec8f6a96e09a582c2cc0"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:299f11b44d8d3a588234adbe01112126010bd96d9139c3ba7b3badd9829261c3"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:cc7d768260f4ba4ea01741c1b5fe3d3a6c70eb91c87f4c8761bbcce5181beafe"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:de599af166970d6a61accde358ec9ded821234cbbc8c6413acfec06056b8e860"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2b24ec55fad43e476905eceaf14f41f6478780b870eda5d08b4d6de9a60b65b4"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9fb815155aac6bfa8d86184079652c9715c812d506b22cfa369196ef4e99d1b4"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-win32.whl", hash = "sha256:7615058aabad54416ddac99ade09a5510cf77039a3b903e94e8922f25ed203d7"},
|
||||
{file = "yarl-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:14bc88baa44e1f84164a392827b5defb4fa8e56b93fecac3d15315e7c8e5d8b3"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:327828786da2006085a4d1feb2594de6f6d26f8af48b81eb1ae950c788d97f61"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cc353841428d56b683a123a813e6a686e07026d6b1c5757970a877195f880c2d"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c73df5b6e8fabe2ddb74876fb82d9dd44cbace0ca12e8861ce9155ad3c886139"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bdff5e0995522706c53078f531fb586f56de9c4c81c243865dd5c66c132c3b5"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06157fb3c58f2736a5e47c8fcbe1afc8b5de6fb28b14d25574af9e62150fcaac"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1654ec814b18be1af2c857aa9000de7a601400bd4c9ca24629b18486c2e35463"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f6595c852ca544aaeeb32d357e62c9c780eac69dcd34e40cae7b55bc4fb1147"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:459e81c2fb920b5f5df744262d1498ec2c8081acdcfe18181da44c50f51312f7"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e48cdb8226644e2fbd0bdb0a0f87906a3db07087f4de77a1b1b1ccfd9e93685"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d9b6b28a57feb51605d6ae5e61a9044a31742db557a3b851a74c13bc61de5172"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e594b22688d5747b06e957f1ef822060cb5cb35b493066e33ceac0cf882188b7"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5f236cb5999ccd23a0ab1bd219cfe0ee3e1c1b65aaf6dd3320e972f7ec3a39da"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a2a64e62c7a0edd07c1c917b0586655f3362d2c2d37d474db1a509efb96fea1c"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d0eea830b591dbc68e030c86a9569826145df485b2b4554874b07fea1275a199"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-win32.whl", hash = "sha256:46ddf6e0b975cd680eb83318aa1d321cb2bf8d288d50f1754526230fcf59ba96"},
|
||||
{file = "yarl-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:117ed8b3732528a1e41af3aa6d4e08483c2f0f2e3d3d7dca7cf538b3516d93df"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2"},
|
||||
{file = "yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8994b29c462de9a8fce2d591028b986dbbe1b32f3ad600b2d3e1c482c93abad6"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f9cbfbc5faca235fbdf531b93aa0f9f005ec7d267d9d738761a4d42b744ea159"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b40d1bf6e6f74f7c0a567a9e5e778bbd4699d1d3d2c0fe46f4b717eef9e96b95"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f5efe0661b9fcd6246f27957f6ae1c0eb29bc60552820f01e970b4996e016004"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5c4804e4039f487e942c13381e6c27b4b4e66066d94ef1fae3f6ba8b953f383"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5d6a6c9602fd4598fa07e0389e19fe199ae96449008d8304bf5d47cb745462e"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4c9156c4d1eb490fe374fb294deeb7bc7eaccda50e23775b2354b6a6739934"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d6324274b4e0e2fa1b3eccb25997b1c9ed134ff61d296448ab8269f5ac068c4c"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d8a8b74d843c2638f3864a17d97a4acda58e40d3e44b6303b8cc3d3c44ae2d29"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:7fac95714b09da9278a0b52e492466f773cfe37651cf467a83a1b659be24bf71"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:c180ac742a083e109c1a18151f4dd8675f32679985a1c750d2ff806796165b55"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:578d00c9b7fccfa1745a44f4eddfdc99d723d157dad26764538fbdda37209857"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:1a3b91c44efa29e6c8ef8a9a2b583347998e2ba52c5d8280dbd5919c02dfc3b5"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a7ac5b4984c468ce4f4a553df281450df0a34aefae02e58d77a0847be8d1e11f"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-win32.whl", hash = "sha256:7294e38f9aa2e9f05f765b28ffdc5d81378508ce6dadbe93f6d464a8c9594473"},
|
||||
{file = "yarl-1.17.1-cp39-cp39-win_amd64.whl", hash = "sha256:eb6dce402734575e1a8cc0bb1509afca508a400a57ce13d306ea2c663bad1138"},
|
||||
{file = "yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06"},
|
||||
{file = "yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
|
|
@ -4298,4 +4313,4 @@ type = ["pytest-mypy"]
|
|||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = "^3.12"
|
||||
content-hash = "8d540e367903004280ea01872d13776353e3a2dc144e6568267da01f35026bf0"
|
||||
content-hash = "a654acc8816c96d1ec25bce16fe1afdd8d37e5ac38a0fa59ee7871dde5ca5229"
|
||||
|
|
|
|||
|
|
@ -37,10 +37,11 @@ sqlmodel = "^0.0.19"
|
|||
tiktoken = "^0.7.0"
|
||||
uvicorn = "0.30.1"
|
||||
typer = "^0.12.5"
|
||||
chromadb = "0.5.11"
|
||||
chromadb = "0.5.16"
|
||||
alembic = "^1.13.2"
|
||||
alembic-postgresql-enum = "^1.2.0"
|
||||
sqlalchemy-utils = "^0.41.2"
|
||||
stripe = "^11.1.1"
|
||||
|
||||
[build-system]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
|
|
|||
|
|
@ -1,26 +1,54 @@
|
|||
import logging
|
||||
import os
|
||||
import importlib
|
||||
from config.config import get_learnhouse_config
|
||||
from fastapi import FastAPI
|
||||
from sqlmodel import SQLModel, Session, create_engine
|
||||
|
||||
def import_all_models():
|
||||
base_dir = 'src/db'
|
||||
base_module_path = 'src.db'
|
||||
|
||||
# Recursively walk through the base directory
|
||||
for root, dirs, files in os.walk(base_dir):
|
||||
# Filter out __init__.py and non-Python files
|
||||
module_files = [f for f in files if f.endswith('.py') and f != '__init__.py']
|
||||
|
||||
# Calculate the module's base path from its directory structure
|
||||
path_diff = os.path.relpath(root, base_dir)
|
||||
if path_diff == '.':
|
||||
current_module_base = base_module_path
|
||||
else:
|
||||
current_module_base = f"{base_module_path}.{path_diff.replace(os.sep, '.')}"
|
||||
|
||||
# Dynamically import each module
|
||||
for file_name in module_files:
|
||||
module_name = file_name[:-3] # Remove the '.py' extension
|
||||
full_module_path = f"{current_module_base}.{module_name}"
|
||||
importlib.import_module(full_module_path)
|
||||
|
||||
# Import all models before creating engine
|
||||
import_all_models()
|
||||
|
||||
learnhouse_config = get_learnhouse_config()
|
||||
engine = create_engine(
|
||||
learnhouse_config.database_config.sql_connection_string, echo=False, pool_pre_ping=True # type: ignore
|
||||
learnhouse_config.database_config.sql_connection_string, # type: ignore
|
||||
echo=False,
|
||||
pool_pre_ping=True # type: ignore
|
||||
)
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
# Create all tables after importing all models
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
async def connect_to_db(app: FastAPI):
|
||||
app.db_engine = engine # type: ignore
|
||||
logging.info("LearnHouse database has been started.")
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def get_db_session():
|
||||
with Session(engine) as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def close_database(app: FastAPI):
|
||||
logging.info("LearnHouse has been shut down.")
|
||||
return app
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class CollectionUpdate(CollectionBase):
|
|||
courses: Optional[list]
|
||||
name: Optional[str]
|
||||
public: Optional[bool]
|
||||
description: Optional[str]
|
||||
description: Optional[str] = ""
|
||||
|
||||
|
||||
class CollectionRead(CollectionBase):
|
||||
|
|
|
|||
|
|
@ -40,7 +40,6 @@ class AssignmentOrgConfig(BaseModel):
|
|||
|
||||
class PaymentOrgConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
stripe_key: str = ""
|
||||
|
||||
|
||||
class DiscussionOrgConfig(BaseModel):
|
||||
|
|
@ -91,7 +90,7 @@ class OrgCloudConfig(BaseModel):
|
|||
|
||||
# Main Config
|
||||
class OrganizationConfigBase(BaseModel):
|
||||
config_version: str = "1.1"
|
||||
config_version: str = "1.2"
|
||||
general: OrgGeneralConfig
|
||||
features: OrgFeatureConfig
|
||||
cloud: OrgCloudConfig
|
||||
|
|
|
|||
46
apps/api/src/db/payments/payments.py
Normal file
46
apps/api/src/db/payments/payments.py
Normal 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
|
||||
15
apps/api/src/db/payments/payments_courses.py
Normal file
15
apps/api/src/db/payments/payments_courses.py
Normal 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())
|
||||
44
apps/api/src/db/payments/payments_products.py
Normal file
44
apps/api/src/db/payments/payments_products.py
Normal 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
|
||||
37
apps/api/src/db/payments/payments_users.py
Normal file
37
apps/api/src/db/payments/payments_users.py
Normal 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())
|
||||
|
||||
|
|
@ -59,6 +59,11 @@ class AnonymousUser(SQLModel):
|
|||
user_uuid: str = "user_anonymous"
|
||||
username: str = "anonymous"
|
||||
|
||||
class InternalUser(SQLModel):
|
||||
id: int = 0
|
||||
user_uuid: str = "user_internal"
|
||||
username: str = "internal"
|
||||
|
||||
|
||||
class User(UserBase, table=True):
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import os
|
||||
from fastapi import APIRouter, Depends
|
||||
from src.routers import health
|
||||
from src.routers import usergroups
|
||||
from src.routers import dev, trail, users, auth, orgs, roles
|
||||
from src.routers.ai import ai
|
||||
from src.routers.courses import chapters, collections, courses, assignments
|
||||
from src.routers.courses.activities import activities, blocks
|
||||
from src.routers.ee import cloud_internal
|
||||
from src.routers.ee import cloud_internal, payments
|
||||
from src.routers.install import install
|
||||
from src.services.dev.dev import isDevModeEnabledOrRaise
|
||||
from src.services.install.install import isInstallModeEnabled
|
||||
|
|
@ -32,6 +33,7 @@ v1_router.include_router(
|
|||
)
|
||||
v1_router.include_router(trail.router, prefix="/trail", tags=["trail"])
|
||||
v1_router.include_router(ai.router, prefix="/ai", tags=["ai"])
|
||||
v1_router.include_router(payments.router, prefix="/payments", tags=["payments"])
|
||||
|
||||
if os.environ.get("CLOUD_INTERNAL_KEY"):
|
||||
v1_router.include_router(
|
||||
|
|
@ -41,6 +43,8 @@ if os.environ.get("CLOUD_INTERNAL_KEY"):
|
|||
dependencies=[Depends(cloud_internal.check_internal_cloud_key)],
|
||||
)
|
||||
|
||||
v1_router.include_router(health.router, prefix="/health", tags=["health"])
|
||||
|
||||
# Dev Routes
|
||||
v1_router.include_router(
|
||||
dev.router,
|
||||
|
|
|
|||
265
apps/api/src/routers/ee/payments.py
Normal file
265
apps/api/src/routers/ee/payments.py
Normal 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)
|
||||
11
apps/api/src/routers/health.py
Normal file
11
apps/api/src/routers/health.py
Normal 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)
|
||||
|
|
@ -142,7 +142,7 @@ async def authorization_verify_based_on_org_admin_status(
|
|||
|
||||
|
||||
# Tested and working
|
||||
async def authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
async def authorization_verify_based_on_roles_and_authorship(
|
||||
request: Request,
|
||||
user_id: int,
|
||||
action: Literal["read", "update", "delete", "create"],
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from sqlmodel import Session, select
|
|||
from src.db.courses.courses import Course
|
||||
from src.db.courses.chapters import Chapter
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_element_is_public,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
|
|
@ -14,6 +14,8 @@ from fastapi import HTTPException, Request
|
|||
from uuid import uuid4
|
||||
from datetime import datetime
|
||||
|
||||
from src.services.payments.payments_access import check_activity_paid_access
|
||||
|
||||
|
||||
####################################################
|
||||
# CRUD
|
||||
|
|
@ -112,7 +114,16 @@ async def get_activity(
|
|||
# RBAC check
|
||||
await rbac_check(request, course.course_uuid, current_user, "read", db_session)
|
||||
|
||||
activity = ActivityRead.model_validate(activity)
|
||||
# Paid access check
|
||||
has_paid_access = await check_activity_paid_access(
|
||||
activity_id=activity.id if activity.id else 0,
|
||||
user=current_user,
|
||||
db_session=db_session
|
||||
)
|
||||
|
||||
activity_read = ActivityRead.model_validate(activity)
|
||||
activity_read.content = activity_read.content if has_paid_access else { "paid_access": False }
|
||||
activity = activity_read
|
||||
|
||||
return activity
|
||||
|
||||
|
|
@ -258,30 +269,32 @@ async def get_activities(
|
|||
|
||||
async def rbac_check(
|
||||
request: Request,
|
||||
course_uuid: str,
|
||||
element_uuid: str,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
action: Literal["create", "read", "update", "delete"],
|
||||
db_session: Session,
|
||||
):
|
||||
|
||||
|
||||
if action == "read":
|
||||
if current_user.id == 0: # Anonymous user
|
||||
res = await authorization_verify_if_element_is_public(
|
||||
request, course_uuid, action, db_session
|
||||
request, element_uuid, action, db_session
|
||||
)
|
||||
return res
|
||||
else:
|
||||
res = await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
request, current_user.id, action, course_uuid, db_session
|
||||
res = await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, action, element_uuid, db_session
|
||||
)
|
||||
return res
|
||||
else:
|
||||
# For non-read actions, proceed with regular RBAC checks
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request,
|
||||
current_user.id,
|
||||
action,
|
||||
course_uuid,
|
||||
element_uuid,
|
||||
db_session,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ from src.security.features_utils.usage import (
|
|||
increase_feature_usage,
|
||||
)
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_element_is_public,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
|
|
@ -1666,7 +1666,7 @@ async def rbac_check(
|
|||
return res
|
||||
else:
|
||||
res = (
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, action, course_uuid, db_session
|
||||
)
|
||||
)
|
||||
|
|
@ -1674,7 +1674,7 @@ async def rbac_check(
|
|||
else:
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request,
|
||||
current_user.id,
|
||||
action,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ from src.db.courses.courses import Course
|
|||
from src.db.organizations import Organization
|
||||
from sqlmodel import Session, select
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
from src.db.courses.chapters import Chapter
|
||||
|
|
@ -150,7 +150,7 @@ async def rbac_check(
|
|||
):
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request,
|
||||
current_user.id,
|
||||
action,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from src.db.organizations import Organization
|
|||
from pydantic import BaseModel
|
||||
from sqlmodel import Session, select
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
from src.db.courses.chapters import Chapter
|
||||
|
|
@ -232,7 +232,7 @@ async def rbac_check(
|
|||
):
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request,
|
||||
current_user.id,
|
||||
action,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from uuid import uuid4
|
|||
from sqlmodel import Session, select
|
||||
from src.db.users import AnonymousUser
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_element_is_public,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
|
|
@ -561,14 +561,14 @@ async def rbac_check(
|
|||
)
|
||||
return res
|
||||
else:
|
||||
res = await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
res = await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, action, course_uuid, db_session
|
||||
)
|
||||
return res
|
||||
else:
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request,
|
||||
current_user.id,
|
||||
action,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from uuid import uuid4
|
|||
from sqlmodel import Session, select
|
||||
from src.db.users import AnonymousUser
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_element_is_public,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
|
|
@ -300,7 +300,7 @@ async def rbac_check(
|
|||
)
|
||||
else:
|
||||
res = (
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, action, collection_uuid, db_session
|
||||
)
|
||||
)
|
||||
|
|
@ -308,7 +308,7 @@ async def rbac_check(
|
|||
else:
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request,
|
||||
current_user.id,
|
||||
action,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
from typing import Literal
|
||||
from typing import Literal, List
|
||||
from uuid import uuid4
|
||||
from sqlalchemy import union
|
||||
from sqlmodel import Session, select
|
||||
from sqlmodel import Session, select, or_, and_
|
||||
from src.db.usergroup_resources import UserGroupResource
|
||||
from src.db.usergroup_user import UserGroupUser
|
||||
from src.db.organizations import Organization
|
||||
|
|
@ -21,7 +20,7 @@ from src.db.courses.courses import (
|
|||
FullCourseReadWithTrail,
|
||||
)
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_element_is_public,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
|
|
@ -151,6 +150,69 @@ async def get_course_meta(
|
|||
trail=trail if trail else None,
|
||||
)
|
||||
|
||||
async def get_courses_orgslug(
|
||||
request: Request,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
org_slug: str,
|
||||
db_session: Session,
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
) -> List[CourseRead]:
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Base query
|
||||
query = (
|
||||
select(Course)
|
||||
.join(Organization)
|
||||
.where(Organization.slug == org_slug)
|
||||
)
|
||||
|
||||
if isinstance(current_user, AnonymousUser):
|
||||
# For anonymous users, only show public courses
|
||||
query = query.where(Course.public == True)
|
||||
else:
|
||||
# For authenticated users, show:
|
||||
# 1. Public courses
|
||||
# 2. Courses not in any UserGroup
|
||||
# 3. Courses in UserGroups where the user is a member
|
||||
# 4. Courses where the user is a resource author
|
||||
query = (
|
||||
query
|
||||
.outerjoin(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid) # type: ignore
|
||||
.outerjoin(UserGroupUser, and_(
|
||||
UserGroupUser.usergroup_id == UserGroupResource.usergroup_id,
|
||||
UserGroupUser.user_id == current_user.id
|
||||
))
|
||||
.outerjoin(ResourceAuthor, ResourceAuthor.resource_uuid == Course.course_uuid) # type: ignore
|
||||
.where(or_(
|
||||
Course.public == True,
|
||||
UserGroupResource.resource_uuid == None, # Courses not in any UserGroup # noqa: E711
|
||||
UserGroupUser.user_id == current_user.id, # Courses in UserGroups where user is a member
|
||||
ResourceAuthor.user_id == current_user.id # Courses where user is a resource author
|
||||
))
|
||||
)
|
||||
|
||||
# Apply pagination
|
||||
query = query.offset(offset).limit(limit).distinct()
|
||||
|
||||
courses = db_session.exec(query).all()
|
||||
|
||||
# Fetch authors for each course
|
||||
course_reads = []
|
||||
for course in courses:
|
||||
authors_query = (
|
||||
select(User)
|
||||
.join(ResourceAuthor, ResourceAuthor.user_id == User.id) # type: ignore
|
||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
||||
)
|
||||
authors = db_session.exec(authors_query).all()
|
||||
|
||||
course_read = CourseRead.model_validate(course)
|
||||
course_read.authors = [UserRead.model_validate(author) for author in authors]
|
||||
course_reads.append(course_read)
|
||||
|
||||
return course_reads
|
||||
|
||||
|
||||
async def create_course(
|
||||
request: Request,
|
||||
|
|
@ -366,72 +428,7 @@ async def delete_course(
|
|||
return {"detail": "Course deleted"}
|
||||
|
||||
|
||||
async def get_courses_orgslug(
|
||||
request: Request,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
org_slug: str,
|
||||
db_session: Session,
|
||||
page: int = 1,
|
||||
limit: int = 10,
|
||||
):
|
||||
|
||||
# TODO : This entire function is a mess. It needs to be rewritten.
|
||||
|
||||
# Query for public courses
|
||||
statement_public = (
|
||||
select(Course)
|
||||
.join(Organization)
|
||||
.where(Organization.slug == org_slug, Course.public == True)
|
||||
)
|
||||
|
||||
# Query for courses where the current user is an author
|
||||
statement_author = (
|
||||
select(Course)
|
||||
.join(Organization)
|
||||
.join(ResourceAuthor, ResourceAuthor.user_id == current_user.id) # type: ignore
|
||||
.where(
|
||||
Organization.slug == org_slug,
|
||||
ResourceAuthor.resource_uuid == Course.course_uuid,
|
||||
)
|
||||
)
|
||||
|
||||
# Query for courses where the current user is in a user group that has access to the course
|
||||
statement_usergroup = (
|
||||
select(Course)
|
||||
.join(Organization)
|
||||
.join(UserGroupResource, UserGroupResource.resource_uuid == Course.course_uuid) # type: ignore
|
||||
.join(
|
||||
UserGroupUser, UserGroupUser.usergroup_id == UserGroupResource.usergroup_id # type: ignore
|
||||
)
|
||||
.where(Organization.slug == org_slug, UserGroupUser.user_id == current_user.id)
|
||||
)
|
||||
|
||||
# Combine the results
|
||||
statement_complete = union(
|
||||
statement_public, statement_author, statement_usergroup
|
||||
).subquery()
|
||||
|
||||
# TODO: migrate this to exec
|
||||
courses = db_session.execute(select(statement_complete)).all() # type: ignore
|
||||
|
||||
# TODO: I have no idea why this is necessary, but it is
|
||||
courses = [CourseRead(**course._asdict(), authors=[]) for course in courses]
|
||||
|
||||
# for every course, get the authors
|
||||
for course in courses:
|
||||
authors_statement = (
|
||||
select(User)
|
||||
.join(ResourceAuthor)
|
||||
.where(ResourceAuthor.resource_uuid == course.course_uuid)
|
||||
)
|
||||
authors = db_session.exec(authors_statement).all()
|
||||
|
||||
# convert from User to UserRead
|
||||
authors = [UserRead.model_validate(author) for author in authors]
|
||||
|
||||
course.authors = authors
|
||||
|
||||
return courses
|
||||
|
||||
|
||||
## 🔒 RBAC Utils ##
|
||||
|
|
@ -452,7 +449,7 @@ async def rbac_check(
|
|||
return res
|
||||
else:
|
||||
res = (
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, action, course_uuid, db_session
|
||||
)
|
||||
)
|
||||
|
|
@ -460,7 +457,7 @@ async def rbac_check(
|
|||
else:
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request,
|
||||
current_user.id,
|
||||
action,
|
||||
|
|
|
|||
0
apps/api/src/services/health/__init__.py
Normal file
0
apps/api/src/services/health/__init__.py
Normal file
21
apps/api/src/services/health/health.py
Normal file
21
apps/api/src/services/health/health.py
Normal 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
|
||||
|
|
@ -330,7 +330,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess
|
|||
|
||||
# Org Config
|
||||
org_config = OrganizationConfigBase(
|
||||
config_version="1.1",
|
||||
config_version="1.2",
|
||||
general=OrgGeneralConfig(
|
||||
enabled=True,
|
||||
color="normal",
|
||||
|
|
@ -345,7 +345,7 @@ def install_create_organization(org_object: OrganizationCreate, db_session: Sess
|
|||
storage=StorageOrgConfig(enabled=True, limit=0),
|
||||
ai=AIOrgConfig(enabled=True, limit=0, model="gpt-4o-mini"),
|
||||
assignments=AssignmentOrgConfig(enabled=True, limit=0),
|
||||
payments=PaymentOrgConfig(enabled=True, stripe_key=""),
|
||||
payments=PaymentOrgConfig(enabled=False),
|
||||
discussions=DiscussionOrgConfig(enabled=True, limit=0),
|
||||
analytics=AnalyticsOrgConfig(enabled=True, limit=0),
|
||||
collaboration=CollaborationOrgConfig(enabled=True, limit=0),
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ from src.security.rbac.rbac import (
|
|||
authorization_verify_based_on_org_admin_status,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
from src.db.users import AnonymousUser, PublicUser
|
||||
from src.db.users import AnonymousUser, InternalUser, PublicUser
|
||||
from src.db.user_organizations import UserOrganization
|
||||
from src.db.organizations import (
|
||||
Organization,
|
||||
|
|
@ -682,7 +682,7 @@ async def get_org_join_mechanism(
|
|||
async def rbac_check(
|
||||
request: Request,
|
||||
org_uuid: str,
|
||||
current_user: PublicUser | AnonymousUser,
|
||||
current_user: PublicUser | AnonymousUser | InternalUser,
|
||||
action: Literal["create", "read", "update", "delete"],
|
||||
db_session: Session,
|
||||
):
|
||||
|
|
@ -690,6 +690,10 @@ async def rbac_check(
|
|||
if action == "read":
|
||||
return True
|
||||
|
||||
# Internal users can do anything
|
||||
if isinstance(current_user, InternalUser):
|
||||
return True
|
||||
|
||||
else:
|
||||
isUserAnon = await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
|
|
|
|||
98
apps/api/src/services/payments/payments_access.py
Normal file
98
apps/api/src/services/payments/payments_access.py
Normal 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)
|
||||
139
apps/api/src/services/payments/payments_config.py
Normal file
139
apps/api/src/services/payments/payments_config.py
Normal 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()
|
||||
123
apps/api/src/services/payments/payments_courses.py
Normal file
123
apps/api/src/services/payments/payments_courses.py
Normal 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
|
||||
50
apps/api/src/services/payments/payments_customers.py
Normal file
50
apps/api/src/services/payments/payments_customers.py
Normal 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
|
||||
216
apps/api/src/services/payments/payments_products.py
Normal file
216
apps/api/src/services/payments/payments_products.py
Normal 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]
|
||||
|
||||
|
||||
473
apps/api/src/services/payments/payments_stripe.py
Normal file
473
apps/api/src/services/payments/payments_stripe.py
Normal 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)}"
|
||||
)
|
||||
250
apps/api/src/services/payments/payments_users.py
Normal file
250
apps/api/src/services/payments/payments_users.py
Normal 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
|
||||
|
||||
59
apps/api/src/services/payments/utils/stripe_utils.py
Normal file
59
apps/api/src/services/payments/utils/stripe_utils.py
Normal 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
|
||||
179
apps/api/src/services/payments/webhooks/payments_webhooks.py
Normal file
179
apps/api/src/services/payments/webhooks/payments_webhooks.py
Normal 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)}")
|
||||
|
|
@ -2,7 +2,7 @@ from typing import Literal
|
|||
from uuid import uuid4
|
||||
from sqlmodel import Session, select
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
from src.db.users import AnonymousUser, PublicUser
|
||||
|
|
@ -133,7 +133,7 @@ async def rbac_check(
|
|||
):
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, action, role_uuid, db_session
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from src.security.features_utils.usage import (
|
|||
increase_feature_usage,
|
||||
)
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
from src.db.usergroup_resources import UserGroupResource
|
||||
|
|
@ -492,7 +492,7 @@ async def rbac_check(
|
|||
):
|
||||
await authorization_verify_if_user_is_anon(current_user.id)
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request,
|
||||
current_user.id,
|
||||
action,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from src.services.orgs.invites import get_invite_code
|
|||
from src.services.users.avatars import upload_avatar
|
||||
from src.db.roles import Role, RoleRead
|
||||
from src.security.rbac.rbac import (
|
||||
authorization_verify_based_on_roles_and_authorship_and_usergroups,
|
||||
authorization_verify_based_on_roles_and_authorship,
|
||||
authorization_verify_if_user_is_anon,
|
||||
)
|
||||
from src.db.organizations import Organization, OrganizationRead
|
||||
|
|
@ -491,7 +491,7 @@ async def authorize_user_action(
|
|||
|
||||
# RBAC check
|
||||
authorized = (
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, action, resource_uuid, db_session
|
||||
)
|
||||
)
|
||||
|
|
@ -564,7 +564,7 @@ async def rbac_check(
|
|||
if current_user.id == 0: # if user is anonymous
|
||||
return True
|
||||
else:
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, "create", "user_x", db_session
|
||||
)
|
||||
|
||||
|
|
@ -575,7 +575,7 @@ async def rbac_check(
|
|||
if current_user.user_uuid == user_uuid:
|
||||
return True
|
||||
|
||||
await authorization_verify_based_on_roles_and_authorship_and_usergroups(
|
||||
await authorization_verify_based_on_roles_and_authorship(
|
||||
request, current_user.id, action, user_uuid, db_session
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from src.services.install.install import (
|
|||
install_default_elements,
|
||||
)
|
||||
|
||||
|
||||
# TODO: Depreceated and need to be removed and remade
|
||||
async def create_initial_data_for_tests(db_session: Session):
|
||||
# Install default elements
|
||||
await install_default_elements({}, db_session)
|
||||
|
|
|
|||
39
apps/web/app/api/health/route.ts
Normal file
39
apps/web/app/api/health/route.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import FormLayout, {
|
|||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { getOrgLogoMediaDirectory } from '@services/media/media'
|
||||
import { AlertTriangle, Info } from 'lucide-react'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
import { OrgProvider } from '@components/Contexts/OrgContext'
|
||||
import ErrorUI from '@components/StyledElements/Error/Error'
|
||||
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import FormLayout, {
|
|||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import Image from 'next/image'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { useFormik } from 'formik'
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import FormLayout, {
|
|||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { getOrgLogoMediaDirectory } from '@services/media/media'
|
||||
import { AlertTriangle, Info } from 'lucide-react'
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import FormLayout, {
|
|||
FormLabelAndMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { AlertTriangle, Check, User } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
@ -110,8 +110,10 @@ function InviteOnlySignUpComponent(props: InviteOnlySignUpProps) {
|
|||
<div className="font-bold text-sm">{message}</div>
|
||||
</div>
|
||||
<hr className="border-green-900/20 800 w-40 border" />
|
||||
<Link className="flex space-x-2 items-center" href={'/login'}>
|
||||
<User size={14} /> <div>Login </div>
|
||||
<Link className="flex space-x-2 items-center" href={
|
||||
`/login?orgslug=${org?.slug}`
|
||||
} >
|
||||
<User size={14} /> <div>Login to your account</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import FormLayout, {
|
|||
FormLabelAndMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { AlertTriangle, Check, User } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import InviteOnlySignUpComponent from './InviteOnlySignUp'
|
|||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { validateInviteCode } from '@services/organizations/invites'
|
||||
import PageLoading from '@components/Objects/Loaders/PageLoading'
|
||||
import Toast from '@components/StyledElements/Toast/Toast'
|
||||
import Toast from '@components/Objects/StyledElements/Toast/Toast'
|
||||
import toast from 'react-hot-toast'
|
||||
import { BarLoader } from 'react-spinners'
|
||||
import { joinOrg } from '@services/organizations/orgs'
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
import React, { useEffect } from 'react'
|
||||
import { INSTALL_STEPS } from './steps/steps'
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Suspense } from 'react'
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import FormLayout, {
|
|||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import { createNewUserInstall, updateInstall } from '@services/install/install'
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import FormLayout, {
|
|||
FormField,
|
||||
FormLabelAndMessage,
|
||||
Input,
|
||||
} from '@components/StyledElements/Form/Form'
|
||||
} from '@components/Objects/StyledElements/Form/Form'
|
||||
import * as Form from '@radix-ui/react-form'
|
||||
import { useFormik } from 'formik'
|
||||
import { BarLoader } from 'react-spinners'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client' // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error'
|
||||
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function Error({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { getCollectionById } from '@services/courses/collections'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
||||
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle'
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { getOrganizationContextInfo } from '@services/organizations/orgs'
|
||||
import { Metadata } from 'next'
|
||||
import Link from 'next/link'
|
||||
import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail'
|
||||
import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton'
|
||||
import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder'
|
||||
import NewCollectionButton from '@components/Objects/StyledElements/Buttons/NewCollectionButton'
|
||||
import { nextAuthOptions } from 'app/auth/options'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { getOrgCollections } from '@services/courses/collections'
|
||||
import { getOrgThumbnailMediaDirectory } from '@services/media/media'
|
||||
import ContentPlaceHolderIfUserIsNotAdmin from '@components/Objects/ContentPlaceHolder'
|
||||
|
||||
type MetadataProps = {
|
||||
params: { orgslug: string; courseid: string }
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { BookOpenCheck, Check, CheckCircle, MoreVertical, UserRoundPen } from 'l
|
|||
import { markActivityAsComplete } from '@services/courses/activity'
|
||||
import DocumentPdfActivity from '@components/Objects/Activities/DocumentPdf/DocumentPdf'
|
||||
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media'
|
||||
|
|
@ -24,8 +24,9 @@ import { AssignmentsTaskProvider } from '@components/Contexts/Assignments/Assign
|
|||
import AssignmentSubmissionProvider, { useAssignmentSubmission } from '@components/Contexts/Assignments/AssignmentSubmissionContext'
|
||||
import toast from 'react-hot-toast'
|
||||
import { mutate } from 'swr'
|
||||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { useMediaQuery } from 'usehooks-ts'
|
||||
import PaidCourseActivityDisclaimer from '@components/Objects/Courses/CourseActions/PaidCourseActivityDisclaimer'
|
||||
|
||||
interface ActivityClientProps {
|
||||
activityid: string
|
||||
|
|
@ -129,7 +130,7 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
</h1>
|
||||
</div>
|
||||
<div className="flex space-x-1 items-center">
|
||||
{activity && activity.published == true && (
|
||||
{activity && activity.published == true && activity.content.paid_access != false && (
|
||||
<AuthenticatedClientElement checkMethod="authentication">
|
||||
{activity.activity_type != 'TYPE_ASSIGNMENT' &&
|
||||
<>
|
||||
|
|
@ -173,14 +174,16 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
)}
|
||||
|
||||
{activity && activity.published == true && (
|
||||
<div
|
||||
className={`p-7 drop-shadow-sm rounded-lg ${bgColor}`}
|
||||
>
|
||||
<>
|
||||
{activity.content.paid_access == false ? (
|
||||
<PaidCourseActivityDisclaimer course={course} />
|
||||
) : (
|
||||
<div className={`p-7 drop-shadow-sm rounded-lg ${bgColor}`}>
|
||||
{/* Activity Types */}
|
||||
<div>
|
||||
{activity.activity_type == 'TYPE_DYNAMIC' && (
|
||||
<Canva content={activity.content} activity={activity} />
|
||||
)}
|
||||
{/* todo : use apis & streams instead of this */}
|
||||
{activity.activity_type == 'TYPE_VIDEO' && (
|
||||
<VideoActivity course={course} activity={activity} />
|
||||
)}
|
||||
|
|
@ -208,6 +211,8 @@ function ActivityClient(props: ActivityClientProps) {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{<div style={{ height: '100px' }}></div>}
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client' // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error'
|
||||
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function Error({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
'use client'
|
||||
import { removeCourse, startCourse } from '@services/courses/activity'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
|
|
@ -7,7 +6,7 @@ import PageLoading from '@components/Objects/Loaders/PageLoading'
|
|||
import { revalidateTags } from '@services/utils/ts/requests'
|
||||
import ActivityIndicators from '@components/Pages/Courses/ActivityIndicators'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
import {
|
||||
getCourseThumbnailMediaDirectory,
|
||||
getUserAvatarMediaDirectory,
|
||||
|
|
@ -15,15 +14,13 @@ import {
|
|||
import { ArrowRight, Backpack, Check, File, Sparkles, Video } from 'lucide-react'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import UserAvatar from '@components/Objects/UserAvatar'
|
||||
import CourseUpdates from '@components/Objects/CourseUpdates/CourseUpdates'
|
||||
import CourseUpdates from '@components/Objects/Courses/CourseUpdates/CourseUpdates'
|
||||
import { CourseProvider } from '@components/Contexts/CourseContext'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useMediaQuery } from 'usehooks-ts'
|
||||
import CoursesActions from '@components/Objects/Courses/CourseActions/CoursesActions'
|
||||
|
||||
const CourseClient = (props: any) => {
|
||||
const [user, setUser] = useState<any>({})
|
||||
const [learnings, setLearnings] = useState<any>([])
|
||||
const session = useLHSession() as any;
|
||||
const courseuuid = props.courseuuid
|
||||
const orgslug = props.orgslug
|
||||
const course = props.course
|
||||
|
|
@ -37,33 +34,6 @@ const CourseClient = (props: any) => {
|
|||
setLearnings(learnings)
|
||||
}
|
||||
|
||||
async function startCourseUI() {
|
||||
// Create activity
|
||||
await startCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
router.refresh()
|
||||
|
||||
// refresh page (FIX for Next.js BUG)
|
||||
// window.location.reload();
|
||||
}
|
||||
|
||||
function isCourseStarted() {
|
||||
const runs = course.trail?.runs
|
||||
if (!runs) return false
|
||||
return runs.some(
|
||||
(run: any) =>
|
||||
run.status === 'STATUS_IN_PROGRESS' && run.course_id === course.id
|
||||
)
|
||||
}
|
||||
|
||||
async function quitCourse() {
|
||||
// Close activity
|
||||
let activity = await removeCourse('course_' + courseuuid, orgslug, session.data?.tokens?.access_token)
|
||||
// Mutate course
|
||||
await revalidateTags(['courses'], orgslug)
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getLearningTags()
|
||||
}, [org, course])
|
||||
|
|
@ -113,11 +83,11 @@ const CourseClient = (props: any) => {
|
|||
course={course}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col md:flex-row pt-10">
|
||||
<div className="course_metadata_left grow space-y-2">
|
||||
<h2 className="py-3 text-2xl font-bold">Description</h2>
|
||||
<div className="flex flex-col md:flex-row md:space-x-10 space-y-6 md:space-y-0 pt-10">
|
||||
<div className="course_metadata_left w-full md:basis-3/4 space-y-2">
|
||||
<h2 className="py-3 text-2xl font-bold">About</h2>
|
||||
<div className="bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden">
|
||||
<p className="py-5 px-5">{course.description}</p>
|
||||
<p className="py-5 px-5 whitespace-pre-wrap">{course.about}</p>
|
||||
</div>
|
||||
|
||||
{learnings.length > 0 && learnings[0] !== 'null' && (
|
||||
|
|
@ -305,60 +275,8 @@ const CourseClient = (props: any) => {
|
|||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="course_metadata_right space-y-3 w-full md:w-72 antialiased flex flex-col md:ml-10 h-fit p-3 py-5 bg-white shadow-md shadow-gray-300/25 outline outline-1 outline-neutral-200/40 rounded-lg overflow-hidden mt-6 md:mt-0">
|
||||
{user && (
|
||||
<div className="flex flex-row md:flex-col mx-auto space-y-0 md:space-y-3 space-x-4 md:space-x-0 px-2 py-2 items-center">
|
||||
<UserAvatar
|
||||
border="border-8"
|
||||
avatar_url={course.authors[0].avatar_image ? getUserAvatarMediaDirectory(course.authors[0].user_uuid, course.authors[0].avatar_image) : ''}
|
||||
predefined_avatar={course.authors[0].avatar_image ? undefined : 'empty'}
|
||||
width={isMobile ? 60 : 100}
|
||||
/>
|
||||
<div className="md:-space-y-2">
|
||||
<div className="text-[12px] text-neutral-400 font-semibold">
|
||||
Author
|
||||
</div>
|
||||
<div className="text-lg md:text-xl font-bold text-neutral-800">
|
||||
{course.authors[0].first_name &&
|
||||
course.authors[0].last_name && (
|
||||
<div className="flex space-x-2 items-center">
|
||||
<p>
|
||||
{course.authors[0].first_name +
|
||||
' ' +
|
||||
course.authors[0].last_name}
|
||||
</p>
|
||||
<span className="text-xs bg-neutral-100 p-1 px-3 rounded-full text-neutral-400 font-semibold">
|
||||
{' '}
|
||||
@{course.authors[0].username}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!course.authors[0].first_name &&
|
||||
!course.authors[0].last_name && (
|
||||
<div className="flex space-x-2 items-center">
|
||||
<p>@{course.authors[0].username}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCourseStarted() ? (
|
||||
<button
|
||||
className="py-2 px-5 mx-auto rounded-xl text-white font-bold h-12 w-full md:w-[200px] drop-shadow-md bg-red-600 hover:bg-red-700 hover:cursor-pointer"
|
||||
onClick={quitCourse}
|
||||
>
|
||||
Quit Course
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="py-2 px-5 mx-auto rounded-xl text-white font-bold h-12 w-full md:w-[200px] drop-shadow-md bg-black hover:bg-gray-900 hover:cursor-pointer"
|
||||
onClick={startCourseUI}
|
||||
>
|
||||
Start Course
|
||||
</button>
|
||||
)}
|
||||
<div className='course_metadata_right basis-1/4'>
|
||||
<CoursesActions courseuuid={courseuuid} orgslug={orgslug} course={course} />
|
||||
</div>
|
||||
</div>
|
||||
</GeneralWrapperStyled>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
'use client'
|
||||
import CreateCourseModal from '@components/Objects/Modals/Course/Create/CreateCourse'
|
||||
import Modal from '@components/StyledElements/Modal/Modal'
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal'
|
||||
import React from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
||||
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'
|
||||
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton'
|
||||
import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton'
|
||||
import useAdminStatus from '@components/Hooks/useAdminStatus'
|
||||
|
||||
interface CourseProps {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client' // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error'
|
||||
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function Error({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client' // Error components must be Client Components
|
||||
|
||||
import ErrorUI from '@components/StyledElements/Error/Error'
|
||||
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export default function Error({
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
'use client'
|
||||
import '@styles/globals.css'
|
||||
import { Menu } from '@components/Objects/Menu/Menu'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import Watermark from '@components/Watermark'
|
||||
import Watermark from '@components/Objects/Watermark'
|
||||
import { OrgMenu } from '@components/Objects/Menus/OrgMenu'
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
|
|
@ -14,7 +14,7 @@ export default function RootLayout({
|
|||
return (
|
||||
<>
|
||||
<SessionProvider>
|
||||
<Menu orgslug={params?.orgslug}></Menu>
|
||||
<OrgMenu orgslug={params?.orgslug}></OrgMenu>
|
||||
{children}
|
||||
<Watermark />
|
||||
</SessionProvider>
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { getUriWithOrg } from '@services/config/config'
|
|||
import { getOrgCourses } from '@services/courses/courses'
|
||||
import Link from 'next/link'
|
||||
import { getOrganizationContextInfo } from '@services/organizations/orgs'
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
|
||||
import CourseThumbnail from '@components/Objects/Thumbnails/CourseThumbnail'
|
||||
import CollectionThumbnail from '@components/Objects/Thumbnails/CollectionThumbnail'
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
||||
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton'
|
||||
import NewCollectionButton from '@components/StyledElements/Buttons/NewCollectionButton'
|
||||
import ContentPlaceHolderIfUserIsNotAdmin from '@components/ContentPlaceHolder'
|
||||
import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton'
|
||||
import NewCollectionButton from '@components/Objects/StyledElements/Buttons/NewCollectionButton'
|
||||
import ContentPlaceHolderIfUserIsNotAdmin from '@components/Objects/ContentPlaceHolder'
|
||||
import { getOrgCollections } from '@services/courses/collections'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { nextAuthOptions } from 'app/auth/options'
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { useLHSession } from '@components/Contexts/LHSessionContext'
|
|||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import PageLoading from '@components/Objects/Loaders/PageLoading'
|
||||
import TrailCourseElement from '@components/Pages/Trail/TrailCourseElement'
|
||||
import TypeOfContentTitle from '@components/StyledElements/Titles/TypeOfContentTitle'
|
||||
import GeneralWrapperStyled from '@components/StyledElements/Wrappers/GeneralWrapper'
|
||||
import TypeOfContentTitle from '@components/Objects/StyledElements/Titles/TypeOfContentTitle'
|
||||
import GeneralWrapperStyled from '@components/Objects/StyledElements/Wrappers/GeneralWrapper'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import { swrFetcher } from '@services/utils/ts/requests'
|
||||
import React, { useEffect } from 'react'
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client';
|
||||
import DashLeftMenu from '@components/Dashboard/UI/DashLeftMenu'
|
||||
import DashMobileMenu from '@components/Dashboard/UI/DashMobileMenu'
|
||||
import DashLeftMenu from '@components/Dashboard/Menus/DashLeftMenu';
|
||||
import DashMobileMenu from '@components/Dashboard/Menus/DashMobileMenu';
|
||||
import AdminAuthorization from '@components/Security/AdminAuthorization'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React from 'react'
|
||||
import { useMediaQuery } from 'usehooks-ts';
|
||||
|
||||
function ClientAdminLayout({
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useAssignments } from '@components/Contexts/Assignments/AssignmentConte
|
|||
import { useAssignmentsTask, useAssignmentsTaskDispatch } from '@components/Contexts/Assignments/AssignmentsTaskContext';
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||
import { useOrg } from '@components/Contexts/OrgContext';
|
||||
import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/StyledElements/Form/Form';
|
||||
import FormLayout, { FormField, FormLabelAndMessage, Input, Textarea } from '@components/Objects/StyledElements/Form/Form';
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import { getActivityByID } from '@services/courses/activities';
|
||||
import { updateAssignmentTask, updateReferenceFile } from '@services/courses/assignments';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useAssignments } from '@components/Contexts/Assignments/AssignmentContext'
|
||||
import Modal from '@components/StyledElements/Modal/Modal';
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal';
|
||||
import { FileUp, ListTodo, PanelLeftOpen, Plus } from 'lucide-react';
|
||||
import React, { useEffect } from 'react'
|
||||
import NewTaskModal from './Modals/NewTaskModal';
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client';
|
||||
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
|
||||
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
|
||||
import { BookOpen, BookX, EllipsisVertical, Eye, Layers2, Monitor, UserRoundPen } from 'lucide-react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { AssignmentProvider, useAssignments } from '@components/Contexts/Assignments/AssignmentContext';
|
||||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip';
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip';
|
||||
import { updateAssignment } from '@services/courses/assignments';
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||
import { mutate } from 'swr';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||
import UserAvatar from '@components/Objects/UserAvatar';
|
||||
import Modal from '@components/StyledElements/Modal/Modal';
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal';
|
||||
import { getAPIUrl } from '@services/config/config';
|
||||
import { getUserAvatarMediaDirectory } from '@services/media/media';
|
||||
import { swrFetcher } from '@services/utils/ts/requests';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext';
|
||||
import { useOrg } from '@components/Contexts/OrgContext';
|
||||
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
|
||||
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
|
||||
import { getAPIUrl, getUriWithOrg } from '@services/config/config';
|
||||
import { getAssignmentsFromACourse } from '@services/courses/assignments';
|
||||
import { getCourseThumbnailMediaDirectory } from '@services/media/media';
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
'use client'
|
||||
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
|
||||
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
|
||||
import CreateCourseModal from '@components/Objects/Modals/Course/Create/CreateCourse'
|
||||
import CourseThumbnail, { removeCoursePrefix } from '@components/Objects/Thumbnails/CourseThumbnail'
|
||||
import AuthenticatedClientElement from '@components/Security/AuthenticatedClientElement'
|
||||
import NewCourseButton from '@components/StyledElements/Buttons/NewCourseButton'
|
||||
import Modal from '@components/StyledElements/Modal/Modal'
|
||||
import NewCourseButton from '@components/Objects/StyledElements/Buttons/NewCourseButton'
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import React from 'react'
|
||||
import useAdminStatus from '@components/Hooks/useAdminStatus'
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
'use client'
|
||||
import EditCourseStructure from '../../../../../../../../components/Dashboard/Course/EditCourseStructure/EditCourseStructure'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import React from 'react'
|
||||
import { CourseProvider } from '../../../../../../../../components/Contexts/CourseContext'
|
||||
import Link from 'next/link'
|
||||
import { CourseOverviewTop } from '@components/Dashboard/UI/CourseOverviewTop'
|
||||
import { CourseOverviewTop } from '@components/Dashboard/Misc/CourseOverviewTop'
|
||||
import { motion } from 'framer-motion'
|
||||
import EditCourseGeneral from '@components/Dashboard/Course/EditCourseGeneral/EditCourseGeneral'
|
||||
import { GalleryVerticalEnd, Info, UserRoundCog } from 'lucide-react'
|
||||
import EditCourseAccess from '@components/Dashboard/Course/EditCourseAccess/EditCourseAccess'
|
||||
import EditCourseStructure from '@components/Dashboard/Pages/Course/EditCourseStructure/EditCourseStructure'
|
||||
import EditCourseGeneral from '@components/Dashboard/Pages/Course/EditCourseGeneral/EditCourseGeneral'
|
||||
import EditCourseAccess from '@components/Dashboard/Pages/Course/EditCourseAccess/EditCourseAccess'
|
||||
|
||||
export type CourseOverviewParams = {
|
||||
orgslug: string
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
'use client'
|
||||
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
|
||||
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { Info } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import OrgEditGeneral from '@components/Dashboard/Org/OrgEditGeneral/OrgEditGeneral'
|
||||
import OrgEditGeneral from '@components/Dashboard/Pages/Org/OrgEditGeneral/OrgEditGeneral'
|
||||
|
||||
export type OrgParams = {
|
||||
subpage: string
|
||||
|
|
|
|||
142
apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx
Normal file
142
apps/web/app/orgs/[orgslug]/dash/payments/[subpage]/page.tsx
Normal 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
|
||||
64
apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx
Normal file
64
apps/web/app/orgs/[orgslug]/dash/user-account/owned/page.tsx
Normal 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
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
'use client'
|
||||
import React, { useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import UserEditGeneral from '@components/Dashboard/UserAccount/UserEditGeneral/UserEditGeneral'
|
||||
import UserEditPassword from '@components/Dashboard/UserAccount/UserEditPassword/UserEditPassword'
|
||||
import UserEditGeneral from '@components/Dashboard/Pages/UserAccount/UserEditGeneral/UserEditGeneral'
|
||||
import UserEditPassword from '@components/Dashboard/Pages/UserAccount/UserEditPassword/UserEditPassword'
|
||||
import Link from 'next/link'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { Info, Lock } from 'lucide-react'
|
||||
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
|
||||
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
||||
export type SettingsParams = {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ import Link from 'next/link'
|
|||
import { useMediaQuery } from 'usehooks-ts'
|
||||
import { getUriWithOrg } from '@services/config/config'
|
||||
import { Monitor, ScanEye, SquareUserRound, UserPlus, Users } from 'lucide-react'
|
||||
import BreadCrumbs from '@components/Dashboard/UI/BreadCrumbs'
|
||||
import BreadCrumbs from '@components/Dashboard/Misc/BreadCrumbs'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import OrgUsers from '@components/Dashboard/Users/OrgUsers/OrgUsers'
|
||||
import OrgAccess from '@components/Dashboard/Users/OrgAccess/OrgAccess'
|
||||
import OrgUsersAdd from '@components/Dashboard/Users/OrgUsersAdd/OrgUsersAdd'
|
||||
import OrgUserGroups from '@components/Dashboard/Users/OrgUserGroups/OrgUserGroups'
|
||||
import OrgUsers from '@components/Dashboard/Pages/Users/OrgUsers/OrgUsers'
|
||||
import OrgAccess from '@components/Dashboard/Pages/Users/OrgAccess/OrgAccess'
|
||||
import OrgUsersAdd from '@components/Dashboard/Pages/Users/OrgUsersAdd/OrgUsersAdd'
|
||||
import OrgUserGroups from '@components/Dashboard/Pages/Users/OrgUserGroups/OrgUserGroups'
|
||||
|
||||
export type SettingsParams = {
|
||||
subpage: string
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
import React from 'react'
|
||||
|
||||
function HealthPage() {
|
||||
return (
|
||||
<div>OK</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HealthPage
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
'use client'
|
||||
import { OrgProvider } from '@components/Contexts/OrgContext'
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import Toast from '@components/StyledElements/Toast/Toast'
|
||||
import Toast from '@components/Objects/StyledElements/Toast/Toast'
|
||||
import '@styles/globals.css'
|
||||
import Onboarding from '@components/Onboarding/Onboarding';
|
||||
import Onboarding from '@components/Objects/Onboarding/Onboarding';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
|
|
|
|||
124
apps/web/app/payments/stripe/connect/oauth/page.tsx
Normal file
124
apps/web/app/payments/stripe/connect/oauth/page.tsx
Normal 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
|
||||
|
|
@ -1,17 +1,13 @@
|
|||
'use client'
|
||||
import PageLoading from '@components/Objects/Loaders/PageLoading';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import React, { useContext, createContext, useEffect } from 'react'
|
||||
import React, { useContext, createContext } from 'react'
|
||||
|
||||
export const SessionContext = createContext({}) as any
|
||||
|
||||
function LHSessionProvider({ children }: { children: React.ReactNode }) {
|
||||
const session = useSession();
|
||||
|
||||
useEffect(() => {
|
||||
}, [])
|
||||
|
||||
|
||||
if (session && session.status == 'loading') {
|
||||
return <PageLoading />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import { swrFetcher } from '@services/utils/ts/requests'
|
|||
import React, { createContext, useContext, useMemo } from 'react'
|
||||
import useSWR from 'swr'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import ErrorUI from '@components/StyledElements/Error/Error'
|
||||
import InfoUI from '@components/StyledElements/Info/Info'
|
||||
import ErrorUI from '@components/Objects/StyledElements/Error/Error'
|
||||
import InfoUI from '@components/Objects/StyledElements/Info/Info'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
export const OrgContext = createContext(null)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
'use client'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
import LearnHouseDashboardLogo from '@public/dashLogo.png'
|
||||
import { Backpack, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
|
||||
import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, Package2, School, Settings, Users, Vault } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import React, { useEffect } from 'react'
|
||||
|
|
@ -11,11 +11,13 @@ import UserAvatar from '../../Objects/UserAvatar'
|
|||
import AdminAuthorization from '@components/Security/AdminAuthorization'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
|
||||
import useFeatureFlag from '@components/Hooks/useFeatureFlag'
|
||||
|
||||
function DashLeftMenu() {
|
||||
const org = useOrg() as any
|
||||
const session = useLHSession() as any
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const isPaymentsEnabled = useFeatureFlag({ path: ['features', 'payments', 'enabled'], defaultValue: false })
|
||||
|
||||
function waitForEverythingToLoad() {
|
||||
if (org && session) {
|
||||
|
|
@ -112,6 +114,16 @@ function DashLeftMenu() {
|
|||
<Users size={18} />
|
||||
</Link>
|
||||
</ToolTip>
|
||||
{isPaymentsEnabled && (
|
||||
<ToolTip content={'Payments'} slateBlack sideOffset={8} side="right">
|
||||
<Link
|
||||
className="bg-white/5 rounded-lg p-2 hover:bg-white/10 transition-all ease-linear"
|
||||
href={`/dash/payments/customers`}
|
||||
>
|
||||
<BadgeDollarSign size={18} />
|
||||
</Link>
|
||||
</ToolTip>
|
||||
)}
|
||||
<ToolTip
|
||||
content={'Organization'}
|
||||
slateBlack
|
||||
|
|
@ -139,7 +151,24 @@ function DashLeftMenu() {
|
|||
<UserAvatar border="border-4" width={35} />
|
||||
</div>
|
||||
</ToolTip>
|
||||
<div className="flex items-center flex-col space-y-1">
|
||||
<div className="flex items-center flex-col space-y-3">
|
||||
<div className="flex flex-col space-y-1 py-1">
|
||||
<ToolTip
|
||||
content={session.data.user.username + "'s Owned Courses"}
|
||||
slateBlack
|
||||
sideOffset={8}
|
||||
side="right"
|
||||
>
|
||||
<Link
|
||||
href={'/dash/user-account/owned'}
|
||||
className="py-1"
|
||||
>
|
||||
<Package2
|
||||
className="mx-auto text-neutral-400 cursor-pointer"
|
||||
size={18}
|
||||
/>
|
||||
</Link>
|
||||
</ToolTip>
|
||||
<ToolTip
|
||||
content={session.data.user.username + "'s Settings"}
|
||||
slateBlack
|
||||
|
|
@ -148,7 +177,7 @@ function DashLeftMenu() {
|
|||
>
|
||||
<Link
|
||||
href={'/dash/user-account/settings/general'}
|
||||
className="py-3"
|
||||
className="py-1"
|
||||
>
|
||||
<Settings
|
||||
className="mx-auto text-neutral-400 cursor-pointer"
|
||||
|
|
@ -156,6 +185,7 @@ function DashLeftMenu() {
|
|||
/>
|
||||
</Link>
|
||||
</ToolTip>
|
||||
</div>
|
||||
<ToolTip
|
||||
content={'Logout'}
|
||||
slateBlack
|
||||
|
|
@ -1,13 +1,13 @@
|
|||
'use client'
|
||||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { Backpack, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
|
||||
import { Backpack, BadgeDollarSign, BookCopy, Home, LogOut, School, Settings, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
import AdminAuthorization from '@components/Security/AdminAuthorization'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { getUriWithOrg, getUriWithoutOrg } from '@services/config/config'
|
||||
import ToolTip from '@components/StyledElements/Tooltip/Tooltip'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
|
||||
function DashMobileMenu() {
|
||||
const org = useOrg() as any
|
||||
|
|
@ -42,6 +42,12 @@ function DashMobileMenu() {
|
|||
<span className="text-xs mt-1">Assignments</span>
|
||||
</Link>
|
||||
</ToolTip>
|
||||
<ToolTip content={'Payments'} slateBlack sideOffset={8} side="top">
|
||||
<Link href={`/dash/payments/customers`} className="flex flex-col items-center p-2">
|
||||
<BadgeDollarSign size={20} />
|
||||
<span className="text-xs mt-1">Payments</span>
|
||||
</Link>
|
||||
</ToolTip>
|
||||
<ToolTip content={'Users'} slateBlack sideOffset={8} side="top">
|
||||
<Link href={`/dash/users/settings/users`} className="flex flex-col items-center p-2">
|
||||
<Users size={20} />
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
'use client';
|
||||
import { useOrg } from '@components/Contexts/OrgContext';
|
||||
import { Backpack, Book, ChevronRight, School, User, Users } from 'lucide-react'
|
||||
import { Backpack, Book, ChevronRight, CreditCard, School, User, Users } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import React from 'react'
|
||||
|
||||
type BreadCrumbsProps = {
|
||||
type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments'
|
||||
type: 'courses' | 'user' | 'users' | 'org' | 'orgusers' | 'assignments' | 'payments'
|
||||
last_breadcrumb?: string
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +65,15 @@ function BreadCrumbs(props: BreadCrumbsProps) {
|
|||
) : (
|
||||
''
|
||||
)}
|
||||
{props.type == 'payments' ? (
|
||||
<div className="flex space-x-2 items-center">
|
||||
{' '}
|
||||
<CreditCard className="text-gray" size={14}></CreditCard>
|
||||
<Link href="/dash/payments">Payments</Link>
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div className="flex items-center space-x-1 first-letter:uppercase">
|
||||
{props.last_breadcrumb ? <ChevronRight size={17} /> : ''}
|
||||
<div className="first-letter:uppercase">
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext'
|
||||
import LinkToUserGroup from '@components/Objects/Modals/Dash/EditCourseAccess/LinkToUserGroup'
|
||||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import Modal from '@components/StyledElements/Modal/Modal'
|
||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import { unLinkResourcesToUserGroup } from '@services/usergroups/usergroups'
|
||||
import { swrFetcher } from '@services/utils/ts/requests'
|
||||
|
|
@ -3,13 +3,13 @@ import FormLayout, {
|
|||
FormLabelAndMessage,
|
||||
Input,
|
||||
Textarea,
|
||||
} from '@components/StyledElements/Form/Form';
|
||||
} from '@components/Objects/StyledElements/Form/Form';
|
||||
import { useFormik } from 'formik';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import * as Form from '@radix-ui/react-form';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useCourse, useCourseDispatch } from '../../../Contexts/CourseContext';
|
||||
import ThumbnailUpdate from './ThumbnailUpdate';
|
||||
import { useCourse, useCourseDispatch } from '@components/Contexts/CourseContext';
|
||||
|
||||
type EditCourseStructureProps = {
|
||||
orgslug: string
|
||||
|
|
@ -3,6 +3,7 @@ import { createApi } from 'unsplash-js';
|
|||
import { Search, X, Cpu, Briefcase, GraduationCap, Heart, Palette, Plane, Utensils,
|
||||
Dumbbell, Music, Shirt, Book, Building, Bike, Camera, Microscope, Coins, Coffee, Gamepad,
|
||||
Flower} from 'lucide-react';
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal';
|
||||
|
||||
const unsplash = createApi({
|
||||
accessKey: process.env.NEXT_PUBLIC_UNSPLASH_ACCESS_KEY as string,
|
||||
|
|
@ -36,9 +37,10 @@ const predefinedLabels = [
|
|||
interface UnsplashImagePickerProps {
|
||||
onSelect: (imageUrl: string) => void;
|
||||
onClose: () => void;
|
||||
isOpen?: boolean;
|
||||
}
|
||||
|
||||
const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onClose }) => {
|
||||
const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onClose, isOpen = true }) => {
|
||||
const [query, setQuery] = useState('');
|
||||
const [images, setImages] = useState<any[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
|
|
@ -54,8 +56,6 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
|
|||
});
|
||||
if (result && result.response) {
|
||||
setImages(prevImages => pageNum === 1 ? result.response.results : [...prevImages, ...result.response.results]);
|
||||
} else {
|
||||
console.error('Unexpected response structure:', result);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching images:', error);
|
||||
|
|
@ -97,16 +97,10 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
|
|||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-3/4 max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold">Choose an image from Unsplash</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mb-4">
|
||||
const modalContent = (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
|
|
@ -116,7 +110,7 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
|
|||
/>
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{predefinedLabels.map(label => (
|
||||
<button
|
||||
key={label.name}
|
||||
|
|
@ -128,6 +122,9 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
|
|||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 pt-0">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{images.map(image => (
|
||||
<div key={image.id} className="relative w-full pb-[56.25%]">
|
||||
|
|
@ -135,7 +132,7 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
|
|||
src={image.urls.small}
|
||||
alt={image.alt_description}
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-lg cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => handleImageSelect(image.urls.full)}
|
||||
onClick={() => handleImageSelect(image.urls.regular)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
@ -144,7 +141,7 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
|
|||
{!loading && images.length > 0 && (
|
||||
<button
|
||||
onClick={handleLoadMore}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
className="mt-4 w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Load More
|
||||
</button>
|
||||
|
|
@ -152,6 +149,18 @@ const UnsplashImagePicker: React.FC<UnsplashImagePickerProps> = ({ onSelect, onC
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
dialogTitle="Choose an image from Unsplash"
|
||||
dialogContent={modalContent}
|
||||
onOpenChange={onClose}
|
||||
isDialogOpen={isOpen}
|
||||
minWidth="lg"
|
||||
minHeight="lg"
|
||||
customHeight="h-[80vh]"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Custom debounce function
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCourse } from '@components/Contexts/CourseContext'
|
||||
import NewActivityModal from '@components/Objects/Modals/Activities/Create/NewActivity'
|
||||
import Modal from '@components/StyledElements/Modal/Modal'
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal'
|
||||
import { getAPIUrl } from '@services/config/config'
|
||||
import {
|
||||
createActivity,
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import { getAPIUrl, getUriWithOrg } from '@services/config/config'
|
||||
import { deleteActivity, updateActivity } from '@services/courses/activities'
|
||||
import { revalidateTags } from '@services/utils/ts/requests'
|
||||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Eye,
|
||||
File,
|
||||
FilePenLine,
|
||||
FileSymlink,
|
||||
Globe,
|
||||
Lock,
|
||||
MoreVertical,
|
||||
|
|
@ -27,6 +28,7 @@ import { useOrg } from '@components/Contexts/OrgContext'
|
|||
import { useCourse } from '@components/Contexts/CourseContext'
|
||||
import toast from 'react-hot-toast'
|
||||
import { useMediaQuery } from 'usehooks-ts'
|
||||
import ToolTip from '@components/Objects/StyledElements/Tooltip/Tooltip'
|
||||
|
||||
type ActivitiyElementProps = {
|
||||
orgslug: string
|
||||
|
|
@ -176,6 +178,8 @@ function ActivityElement(props: ActivitiyElementProps) {
|
|||
)}
|
||||
<span>{!props.activity.published ? 'Publish' : 'Unpublish'}</span>
|
||||
</button>
|
||||
<div className="w-px h-3 bg-gray-300 mx-1 self-center rounded-full hidden sm:block" />
|
||||
<ToolTip content="Preview Activity" sideOffset={8}>
|
||||
<Link
|
||||
href={
|
||||
getUriWithOrg(props.orgslug, '') +
|
||||
|
|
@ -191,9 +195,9 @@ function ActivityElement(props: ActivitiyElementProps) {
|
|||
className="p-1 px-2 sm:px-3 bg-gradient-to-bl text-cyan-800 from-sky-400/50 to-cyan-200/80 border border-cyan-600/10 shadow-md rounded-md font-bold text-xs flex items-center space-x-1 transition-colors duration-200 hover:from-sky-500/50 hover:to-cyan-300/80"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Eye strokeWidth={2} size={12} className="text-sky-600" />
|
||||
<span>Preview</span>
|
||||
<Eye strokeWidth={2} size={14} className="text-sky-600" />
|
||||
</Link>
|
||||
</ToolTip>
|
||||
{/* Delete Button */}
|
||||
<ConfirmationModal
|
||||
confirmationMessage="Are you sure you want to delete this activity ?"
|
||||
|
|
@ -205,7 +209,6 @@ function ActivityElement(props: ActivitiyElementProps) {
|
|||
rel="noopener noreferrer"
|
||||
>
|
||||
<X size={15} className="text-rose-200 font-bold" />
|
||||
{!isMobile && <span className="text-rose-200 font-bold text-xs">Delete</span>}
|
||||
</button>
|
||||
}
|
||||
functionToExecute={() => deleteActivityUI()}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import ConfirmationModal from '@components/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import ConfirmationModal from '@components/Objects/StyledElements/ConfirmationModal/ConfirmationModal'
|
||||
import {
|
||||
Hexagon,
|
||||
MoreHorizontal,
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
useCourseDispatch,
|
||||
} from '@components/Contexts/CourseContext'
|
||||
import { Hexagon } from 'lucide-react'
|
||||
import Modal from '@components/StyledElements/Modal/Modal'
|
||||
import Modal from '@components/Objects/StyledElements/Modal/Modal'
|
||||
import NewChapterModal from '@components/Objects/Modals/Chapters/NewChapter'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ import { useRouter } from 'next/navigation'
|
|||
import { useOrg } from '@components/Contexts/OrgContext'
|
||||
import { useLHSession } from '@components/Contexts/LHSessionContext'
|
||||
import { getOrgLogoMediaDirectory, getOrgThumbnailMediaDirectory } from '@services/media/media'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs"
|
||||
import { Toaster, toast } from 'react-hot-toast';
|
||||
import { constructAcceptValue } from '@/lib/constants';
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue