testing deployment
Browse files- .gitignore +2 -1
- App/.gitignore +1 -0
- App/Payments/Model.py +68 -0
- App/Payments/PaymentsRoutes.py +147 -0
- App/Payments/Schema.py +70 -0
- App/Plans/Model.py +18 -0
- App/Plans/PlanRoutes.py +99 -0
- App/Plans/Schema.py +47 -0
- App/Portals/Model.py +24 -0
- App/Portals/PortalRoutes.py +82 -0
- App/Portals/Schema.py +56 -0
- App/Subscriptions/BackgroundTask.py +21 -0
- App/Subscriptions/Model.py +67 -0
- App/Subscriptions/Schema.py +43 -0
- App/Subscriptions/SubscriptionRoutes.py +156 -0
- App/Users/Model.py +37 -38
- App/Users/{Schemas.py → Schema.py} +31 -56
- App/Users/UserRoutes.py +70 -73
- App/app.py +13 -2
- App/discovery.py +20 -11
- App/modelInit.py +28 -7
- App/requirements.txt +9 -0
- App/vercel.json +15 -0
- Dockerfile +17 -6
- call.py +5 -0
- requirements.txt +3 -1
- vercel.json +14 -0
.gitignore
CHANGED
@@ -1,2 +1,3 @@
|
|
1 |
*.pyc
|
2 |
-
*sqlite3*
|
|
|
|
1 |
*.pyc
|
2 |
+
*sqlite3*
|
3 |
+
.vercel
|
App/.gitignore
ADDED
@@ -0,0 +1 @@
|
|
|
|
|
1 |
+
.vercel
|
App/Payments/Model.py
ADDED
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from tortoise import fields
|
2 |
+
from tortoise.models import Model
|
3 |
+
import datetime
|
4 |
+
from decimal import Decimal
|
5 |
+
from App.Users.Model import User
|
6 |
+
from App.Plans.Model import Plan
|
7 |
+
from App.Subscriptions.Model import Subscription
|
8 |
+
from .Schema import PaymentMethod
|
9 |
+
|
10 |
+
|
11 |
+
class Payment(Model):
|
12 |
+
id = fields.UUIDField(pk=True)
|
13 |
+
user = fields.ForeignKeyField("models.User", related_name="payments", null=True)
|
14 |
+
plan = fields.ForeignKeyField(
|
15 |
+
"models.Plan",
|
16 |
+
related_name="payments",
|
17 |
+
null=True,
|
18 |
+
description="Plan associated with the payment",
|
19 |
+
)
|
20 |
+
amount = fields.DecimalField(
|
21 |
+
max_digits=10, decimal_places=2, description="Payment amount"
|
22 |
+
)
|
23 |
+
status = fields.CharField(
|
24 |
+
max_length=50,
|
25 |
+
default="pending",
|
26 |
+
description="Payment status (e.g., pending, completed, failed, balance-assigned)",
|
27 |
+
)
|
28 |
+
payment_method = fields.CharField(
|
29 |
+
max_length=50, choices=PaymentMethod.CHOICES, description="Payment method"
|
30 |
+
)
|
31 |
+
transaction_id = fields.CharField(
|
32 |
+
max_length=100,
|
33 |
+
unique=True,
|
34 |
+
null=True,
|
35 |
+
description="Unique transaction ID for payment (for methods like Lipa Number)",
|
36 |
+
)
|
37 |
+
created_time = fields.DatetimeField(auto_now_add=True)
|
38 |
+
updated_time = fields.DatetimeField(auto_now=True)
|
39 |
+
|
40 |
+
class Meta:
|
41 |
+
table = "payments"
|
42 |
+
|
43 |
+
async def create_subscription_if_cash(self):
|
44 |
+
"""
|
45 |
+
Creates a subscription for the user if the payment method is 'cash'
|
46 |
+
and the user has enough balance for the specified plan.
|
47 |
+
"""
|
48 |
+
if self.payment_method == PaymentMethod.CASH and self.user and self.plan:
|
49 |
+
if self.amount >= self.plan.amount:
|
50 |
+
expiration_time = datetime.datetime.now() + datetime.timedelta(
|
51 |
+
hours=self.plan.duration
|
52 |
+
)
|
53 |
+
|
54 |
+
# Create the subscription
|
55 |
+
await Subscription.create(
|
56 |
+
user=self.user,
|
57 |
+
duration=self.plan.duration,
|
58 |
+
download_mb=self.plan.download_speed
|
59 |
+
* 1024, # Converting Mbps to MB
|
60 |
+
upload_mb=self.plan.upload_speed * 1024, # Converting Mbps to MB
|
61 |
+
created_time=datetime.datetime.now(),
|
62 |
+
expiration_time=expiration_time,
|
63 |
+
active=True,
|
64 |
+
)
|
65 |
+
self.status = "subscription-created"
|
66 |
+
else:
|
67 |
+
self.status = "insufficient-funds"
|
68 |
+
await self.save()
|
App/Payments/PaymentsRoutes.py
ADDED
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, HTTPException, status
|
2 |
+
from typing import List
|
3 |
+
from .Model import Payment
|
4 |
+
from App.Users.Model import User
|
5 |
+
from App.Plans.Model import Plan
|
6 |
+
from .Schema import (
|
7 |
+
CreatePaymentRequest,
|
8 |
+
UpdatePaymentStatusRequest,
|
9 |
+
PaymentResponse,
|
10 |
+
UpdatePaymentUserRequest,
|
11 |
+
BaseResponse,
|
12 |
+
PaymentListResponse,
|
13 |
+
)
|
14 |
+
from .Schema import PaymentMethod
|
15 |
+
|
16 |
+
payment_router = APIRouter(tags=["Payments"])
|
17 |
+
|
18 |
+
|
19 |
+
@payment_router.post("/payment/create", response_model=BaseResponse)
|
20 |
+
async def create_payment(request: CreatePaymentRequest):
|
21 |
+
# If payment method is "Lipa Number", transaction_id is required
|
22 |
+
if (
|
23 |
+
request.payment_method == PaymentMethod.LIPA_NUMBER
|
24 |
+
and not request.transaction_id
|
25 |
+
):
|
26 |
+
raise HTTPException(
|
27 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
28 |
+
detail="Transaction ID is required for Lipa Number payments",
|
29 |
+
)
|
30 |
+
|
31 |
+
# Check if plan exists
|
32 |
+
plan = await Plan.get_or_none(id=request.plan_id)
|
33 |
+
if request.plan_id and not plan:
|
34 |
+
raise HTTPException(
|
35 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
|
36 |
+
)
|
37 |
+
|
38 |
+
# Create new payment without linking a user initially
|
39 |
+
payment = await Payment.create(
|
40 |
+
user_id=request.user_id,
|
41 |
+
plan=plan,
|
42 |
+
amount=request.amount,
|
43 |
+
payment_method=request.payment_method,
|
44 |
+
transaction_id=request.transaction_id,
|
45 |
+
status="pending", # Default status
|
46 |
+
)
|
47 |
+
|
48 |
+
# If payment method is "cash", attempt to create a subscription
|
49 |
+
if payment.payment_method == PaymentMethod.CASH:
|
50 |
+
await payment.create_subscription_if_cash()
|
51 |
+
|
52 |
+
await payment.save()
|
53 |
+
|
54 |
+
return BaseResponse(
|
55 |
+
code=200,
|
56 |
+
message="Payment created successfully",
|
57 |
+
payload={"payment_id": str(payment.id)},
|
58 |
+
)
|
59 |
+
|
60 |
+
|
61 |
+
@payment_router.get("/payment/{payment_id}", response_model=PaymentResponse)
|
62 |
+
async def get_payment(payment_id: str):
|
63 |
+
payment = await Payment.get_or_none(id=payment_id)
|
64 |
+
if not payment:
|
65 |
+
raise HTTPException(
|
66 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found"
|
67 |
+
)
|
68 |
+
|
69 |
+
return PaymentResponse(
|
70 |
+
id=str(payment.id),
|
71 |
+
user_id=payment.user_id,
|
72 |
+
plan_id=payment.plan_id,
|
73 |
+
amount=payment.amount,
|
74 |
+
payment_method=payment.payment_method,
|
75 |
+
status=payment.status,
|
76 |
+
transaction_id=payment.transaction_id,
|
77 |
+
created_time=payment.created_time,
|
78 |
+
updated_time=payment.updated_time,
|
79 |
+
)
|
80 |
+
|
81 |
+
|
82 |
+
@payment_router.get("/payments/unlinked", response_model=PaymentListResponse)
|
83 |
+
async def get_unlinked_payments():
|
84 |
+
unlinked_payments = await Payment.filter(user_id=None)
|
85 |
+
result = [
|
86 |
+
PaymentResponse(
|
87 |
+
id=str(payment.id),
|
88 |
+
user_id=payment.user_id,
|
89 |
+
plan_id=payment.plan_id,
|
90 |
+
amount=payment.amount,
|
91 |
+
payment_method=payment.payment_method,
|
92 |
+
status=payment.status,
|
93 |
+
transaction_id=payment.transaction_id,
|
94 |
+
created_time=payment.created_time,
|
95 |
+
updated_time=payment.updated_time,
|
96 |
+
)
|
97 |
+
for payment in unlinked_payments
|
98 |
+
]
|
99 |
+
|
100 |
+
return PaymentListResponse(payments=result, total_count=len(result))
|
101 |
+
|
102 |
+
|
103 |
+
@payment_router.put("/payment/{payment_id}/link-user", response_model=BaseResponse)
|
104 |
+
async def link_user_to_payment(payment_id: str, request: UpdatePaymentUserRequest):
|
105 |
+
payment = await Payment.get_or_none(id=payment_id)
|
106 |
+
if not payment:
|
107 |
+
raise HTTPException(
|
108 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Payment not found"
|
109 |
+
)
|
110 |
+
|
111 |
+
user = await User.get_or_none(id=request.user_id)
|
112 |
+
if not user:
|
113 |
+
raise HTTPException(
|
114 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
115 |
+
)
|
116 |
+
|
117 |
+
# Link payment to the user
|
118 |
+
payment.user_id = request.user_id
|
119 |
+
await payment.save()
|
120 |
+
|
121 |
+
# If the payment method is "Lipa Number" or "M-Pesa", add to user's balance directly
|
122 |
+
if (
|
123 |
+
payment.payment_method in [PaymentMethod.LIPA_NUMBER, PaymentMethod.MPESA]
|
124 |
+
and not payment.plan
|
125 |
+
):
|
126 |
+
user.balance += payment.amount
|
127 |
+
await user.save()
|
128 |
+
|
129 |
+
# Update payment status to indicate it was assigned to balance
|
130 |
+
payment.status = "balance-assigned"
|
131 |
+
await payment.save()
|
132 |
+
|
133 |
+
return BaseResponse(
|
134 |
+
code=200,
|
135 |
+
message="Payment linked to user and amount added to balance successfully",
|
136 |
+
payload={
|
137 |
+
"payment_id": str(payment.id),
|
138 |
+
"user_id": request.user_id,
|
139 |
+
"new_balance": user.balance,
|
140 |
+
},
|
141 |
+
)
|
142 |
+
|
143 |
+
return BaseResponse(
|
144 |
+
code=200,
|
145 |
+
message="Payment linked to user successfully",
|
146 |
+
payload={"payment_id": str(payment.id), "user_id": request.user_id},
|
147 |
+
)
|
App/Payments/Schema.py
ADDED
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, Field
|
2 |
+
from typing import Optional, List
|
3 |
+
from decimal import Decimal
|
4 |
+
from datetime import datetime
|
5 |
+
|
6 |
+
|
7 |
+
class BaseResponse(BaseModel):
|
8 |
+
code: int
|
9 |
+
message: str
|
10 |
+
payload: Optional[dict] = None
|
11 |
+
|
12 |
+
|
13 |
+
class PaymentMethod:
|
14 |
+
CASH = "cash"
|
15 |
+
MPESA = "mpesa"
|
16 |
+
LIPA_NUMBER = "lipa_number"
|
17 |
+
CREDIT_CARD = "credit_card"
|
18 |
+
|
19 |
+
CHOICES = [CASH, MPESA, LIPA_NUMBER, CREDIT_CARD]
|
20 |
+
|
21 |
+
|
22 |
+
class CreatePaymentRequest(BaseModel):
|
23 |
+
user_id: Optional[str] = Field(
|
24 |
+
None, description="ID of the user making the payment"
|
25 |
+
)
|
26 |
+
plan_id: Optional[str] = Field(
|
27 |
+
None, description="ID of the plan associated with the payment"
|
28 |
+
)
|
29 |
+
amount: Decimal = Field(..., description="Payment amount")
|
30 |
+
payment_method: str = Field(
|
31 |
+
..., description="Method of payment", example=PaymentMethod.CASH
|
32 |
+
)
|
33 |
+
transaction_id: Optional[str] = Field(
|
34 |
+
None, description="Unique transaction ID (for methods like Lipa Number)"
|
35 |
+
)
|
36 |
+
|
37 |
+
|
38 |
+
class UpdatePaymentStatusRequest(BaseModel):
|
39 |
+
status: str = Field(
|
40 |
+
..., description="Status of the payment (e.g., pending, completed, failed)"
|
41 |
+
)
|
42 |
+
|
43 |
+
|
44 |
+
class PaymentResponse(BaseModel):
|
45 |
+
id: str
|
46 |
+
user_id: Optional[str]
|
47 |
+
plan_id: Optional[str]
|
48 |
+
amount: Decimal
|
49 |
+
payment_method: str
|
50 |
+
status: str
|
51 |
+
transaction_id: Optional[str]
|
52 |
+
created_time: datetime
|
53 |
+
updated_time: datetime
|
54 |
+
|
55 |
+
class Config:
|
56 |
+
from_attributes = True
|
57 |
+
|
58 |
+
|
59 |
+
class PaymentListResponse(BaseModel):
|
60 |
+
payments: List[PaymentResponse]
|
61 |
+
total_count: int
|
62 |
+
|
63 |
+
class Config:
|
64 |
+
from_attributes = True
|
65 |
+
|
66 |
+
|
67 |
+
class UpdatePaymentUserRequest(BaseModel):
|
68 |
+
user_id: str = Field(
|
69 |
+
..., description="ID of the user to link or update for this payment"
|
70 |
+
)
|
App/Plans/Model.py
ADDED
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from tortoise import fields
|
2 |
+
from tortoise.models import Model
|
3 |
+
|
4 |
+
|
5 |
+
class Plan(Model):
|
6 |
+
id = fields.UUIDField(pk=True)
|
7 |
+
name = fields.CharField(
|
8 |
+
max_length=100, unique=True, description="Name of the subscription plan"
|
9 |
+
)
|
10 |
+
amount = fields.DecimalField(
|
11 |
+
max_digits=10, decimal_places=2, description="Cost of the plan"
|
12 |
+
)
|
13 |
+
duration = fields.IntField(description="Duration of the subscription in hours")
|
14 |
+
download_speed = fields.FloatField(description="Download speed in Mbps")
|
15 |
+
upload_speed = fields.FloatField(description="Upload speed in Mbps")
|
16 |
+
|
17 |
+
class Meta:
|
18 |
+
table = "plans"
|
App/Plans/PlanRoutes.py
ADDED
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
2 |
+
from typing import List
|
3 |
+
from .Model import Plan
|
4 |
+
from .Schema import (
|
5 |
+
CreatePlanRequest,
|
6 |
+
UpdatePlanRequest,
|
7 |
+
PlanResponse,
|
8 |
+
PlanListResponse,
|
9 |
+
BaseResponse,
|
10 |
+
)
|
11 |
+
|
12 |
+
plan_router = APIRouter(tags=["Plans"])
|
13 |
+
|
14 |
+
|
15 |
+
@plan_router.post("/plan/create", response_model=BaseResponse)
|
16 |
+
async def create_plan(request: CreatePlanRequest):
|
17 |
+
# Check if a plan with the same name already exists
|
18 |
+
existing_plan = await Plan.get_or_none(name=request.name)
|
19 |
+
if existing_plan:
|
20 |
+
raise HTTPException(
|
21 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
22 |
+
detail="A plan with this name already exists",
|
23 |
+
)
|
24 |
+
|
25 |
+
# Create a new plan
|
26 |
+
plan = await Plan.create(
|
27 |
+
name=request.name,
|
28 |
+
amount=request.amount,
|
29 |
+
duration=request.duration,
|
30 |
+
download_speed=request.download_speed,
|
31 |
+
upload_speed=request.upload_speed,
|
32 |
+
)
|
33 |
+
await plan.save()
|
34 |
+
|
35 |
+
return BaseResponse(
|
36 |
+
code=200, message="Plan created successfully", payload={"plan_id": str(plan.id)}
|
37 |
+
)
|
38 |
+
|
39 |
+
|
40 |
+
@plan_router.put("/plan/{plan_id}/update", response_model=BaseResponse)
|
41 |
+
async def update_plan(plan_id: str, request: UpdatePlanRequest):
|
42 |
+
# Find the plan by ID
|
43 |
+
plan = await Plan.get_or_none(id=plan_id)
|
44 |
+
if not plan:
|
45 |
+
raise HTTPException(
|
46 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
|
47 |
+
)
|
48 |
+
|
49 |
+
# Update the plan fields if provided
|
50 |
+
if request.name is not None:
|
51 |
+
plan.name = request.name
|
52 |
+
if request.amount is not None:
|
53 |
+
plan.amount = request.amount
|
54 |
+
if request.duration is not None:
|
55 |
+
plan.duration = request.duration
|
56 |
+
if request.download_speed is not None:
|
57 |
+
plan.download_speed = request.download_speed
|
58 |
+
if request.upload_speed is not None:
|
59 |
+
plan.upload_speed = request.upload_speed
|
60 |
+
|
61 |
+
await plan.save()
|
62 |
+
|
63 |
+
return BaseResponse(
|
64 |
+
code=200, message="Plan updated successfully", payload={"plan_id": str(plan.id)}
|
65 |
+
)
|
66 |
+
|
67 |
+
|
68 |
+
@plan_router.delete("/plan/{plan_id}/delete", response_model=BaseResponse)
|
69 |
+
async def delete_plan(plan_id: str):
|
70 |
+
# Find the plan by ID
|
71 |
+
plan = await Plan.get_or_none(id=plan_id)
|
72 |
+
if not plan:
|
73 |
+
raise HTTPException(
|
74 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
|
75 |
+
)
|
76 |
+
|
77 |
+
await plan.delete()
|
78 |
+
|
79 |
+
return BaseResponse(code=200, message="Plan deleted successfully")
|
80 |
+
|
81 |
+
|
82 |
+
@plan_router.get("/plans", response_model=PlanListResponse)
|
83 |
+
async def list_plans():
|
84 |
+
plans = await Plan.all()
|
85 |
+
total_count = await Plan.all().count()
|
86 |
+
|
87 |
+
result = [
|
88 |
+
PlanResponse(
|
89 |
+
id=str(plan.id),
|
90 |
+
name=plan.name,
|
91 |
+
amount=plan.amount,
|
92 |
+
duration=plan.duration,
|
93 |
+
download_speed=plan.download_speed,
|
94 |
+
upload_speed=plan.upload_speed,
|
95 |
+
)
|
96 |
+
for plan in plans
|
97 |
+
]
|
98 |
+
|
99 |
+
return PlanListResponse(plans=result, total_count=total_count)
|
App/Plans/Schema.py
ADDED
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, Field
|
2 |
+
from typing import Optional, List
|
3 |
+
from decimal import Decimal
|
4 |
+
|
5 |
+
|
6 |
+
class BaseResponse(BaseModel):
|
7 |
+
code: int
|
8 |
+
message: str
|
9 |
+
payload: Optional[dict] = None
|
10 |
+
|
11 |
+
|
12 |
+
class CreatePlanRequest(BaseModel):
|
13 |
+
name: str = Field(..., description="Name of the subscription plan")
|
14 |
+
amount: Decimal = Field(..., description="Cost of the plan")
|
15 |
+
duration: int = Field(..., description="Duration of the subscription in hours")
|
16 |
+
download_speed: float = Field(..., description="Download speed in Mbps")
|
17 |
+
upload_speed: float = Field(..., description="Upload speed in Mbps")
|
18 |
+
|
19 |
+
|
20 |
+
class UpdatePlanRequest(BaseModel):
|
21 |
+
name: Optional[str] = Field(None, description="Name of the subscription plan")
|
22 |
+
amount: Optional[Decimal] = Field(None, description="Cost of the plan")
|
23 |
+
duration: Optional[int] = Field(
|
24 |
+
None, description="Duration of the subscription in hours"
|
25 |
+
)
|
26 |
+
download_speed: Optional[float] = Field(None, description="Download speed in Mbps")
|
27 |
+
upload_speed: Optional[float] = Field(None, description="Upload speed in Mbps")
|
28 |
+
|
29 |
+
|
30 |
+
class PlanResponse(BaseModel):
|
31 |
+
id: str
|
32 |
+
name: str
|
33 |
+
amount: Decimal
|
34 |
+
duration: int
|
35 |
+
download_speed: float
|
36 |
+
upload_speed: float
|
37 |
+
|
38 |
+
class Config:
|
39 |
+
from_attributes = True
|
40 |
+
|
41 |
+
|
42 |
+
class PlanListResponse(BaseModel):
|
43 |
+
plans: List[PlanResponse]
|
44 |
+
total_count: int
|
45 |
+
|
46 |
+
class Config:
|
47 |
+
from_attributes = True
|
App/Portals/Model.py
ADDED
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from tortoise import fields, models
|
2 |
+
|
3 |
+
|
4 |
+
class Portal(models.Model):
|
5 |
+
id = fields.IntField(pk=True)
|
6 |
+
name = fields.CharField(
|
7 |
+
max_length=50,
|
8 |
+
unique=True,
|
9 |
+
description="Name of the portal, e.g., Android or MikroTik",
|
10 |
+
)
|
11 |
+
description = fields.CharField(
|
12 |
+
max_length=255,
|
13 |
+
description="Description of the portal",
|
14 |
+
)
|
15 |
+
url = fields.CharField(
|
16 |
+
max_length=255,
|
17 |
+
description="URL of the portal, must start with http or https",
|
18 |
+
)
|
19 |
+
|
20 |
+
class Meta:
|
21 |
+
table = "portals"
|
22 |
+
|
23 |
+
def __str__(self):
|
24 |
+
return f"{self.name} - {self.url}"
|
App/Portals/PortalRoutes.py
ADDED
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, HTTPException, status
|
2 |
+
from .Schema import (
|
3 |
+
CreatePortalRequest,
|
4 |
+
UpdatePortalRequest,
|
5 |
+
PortalResponse,
|
6 |
+
BaseResponse,
|
7 |
+
)
|
8 |
+
from .Model import Portal
|
9 |
+
|
10 |
+
portal_router = APIRouter(tags=["Portals"])
|
11 |
+
|
12 |
+
# Constants for portal names
|
13 |
+
ANDROID = "Android"
|
14 |
+
MIKROTIK = "MikroTik"
|
15 |
+
|
16 |
+
|
17 |
+
# Get Portal by Name (Android or MikroTik)
|
18 |
+
@portal_router.get(
|
19 |
+
"/portals/{portal_name}",
|
20 |
+
response_model=PortalResponse,
|
21 |
+
status_code=status.HTTP_200_OK,
|
22 |
+
)
|
23 |
+
async def get_portal_by_name(portal_name: str):
|
24 |
+
portal = await Portal.get_or_none(name=portal_name)
|
25 |
+
if not portal:
|
26 |
+
raise HTTPException(
|
27 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
28 |
+
detail=f"Portal '{portal_name}' not found.",
|
29 |
+
)
|
30 |
+
|
31 |
+
return PortalResponse.from_orm(portal)
|
32 |
+
|
33 |
+
|
34 |
+
# Assign or Update Android Portal
|
35 |
+
@portal_router.put(
|
36 |
+
"/portals/android", response_model=BaseResponse, status_code=status.HTTP_200_OK
|
37 |
+
)
|
38 |
+
async def update_android_portal(request: UpdatePortalRequest):
|
39 |
+
|
40 |
+
try:
|
41 |
+
portal, created = await Portal.get_or_create(name=ANDROID)
|
42 |
+
except:
|
43 |
+
portal, created = await Portal.get_or_create(
|
44 |
+
name=ANDROID, description="Android-Link", url="https://example.com"
|
45 |
+
)
|
46 |
+
|
47 |
+
portal_data = request.dict(exclude_unset=True)
|
48 |
+
for key, value in portal_data.items():
|
49 |
+
setattr(portal, key, value)
|
50 |
+
|
51 |
+
await portal.save()
|
52 |
+
message = (
|
53 |
+
"Android portal created successfully"
|
54 |
+
if created
|
55 |
+
else "Android portal updated successfully"
|
56 |
+
)
|
57 |
+
return BaseResponse(code=200, message=message, payload={"portal_name": ANDROID})
|
58 |
+
|
59 |
+
|
60 |
+
# Assign or Update MikroTik Portal
|
61 |
+
@portal_router.put(
|
62 |
+
"/portals/mikrotik", response_model=BaseResponse, status_code=status.HTTP_200_OK
|
63 |
+
)
|
64 |
+
async def update_mikrotik_portal(request: UpdatePortalRequest):
|
65 |
+
try:
|
66 |
+
portal, created = await Portal.get_or_create(name=MIKROTIK)
|
67 |
+
except:
|
68 |
+
portal, created = await Portal.get_or_create(
|
69 |
+
name=MIKROTIK, description="Android-Link", url="https://example.com"
|
70 |
+
)
|
71 |
+
|
72 |
+
portal_data = request.dict(exclude_unset=True)
|
73 |
+
for key, value in portal_data.items():
|
74 |
+
setattr(portal, key, value)
|
75 |
+
|
76 |
+
await portal.save()
|
77 |
+
message = (
|
78 |
+
"MikroTik portal created successfully"
|
79 |
+
if created
|
80 |
+
else "MikroTik portal updated successfully"
|
81 |
+
)
|
82 |
+
return BaseResponse(code=200, message=message, payload={"portal_name": MIKROTIK})
|
App/Portals/Schema.py
ADDED
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, Field, HttpUrl
|
2 |
+
from typing import List, Optional
|
3 |
+
|
4 |
+
|
5 |
+
class BaseResponse(BaseModel):
|
6 |
+
code: int
|
7 |
+
message: str
|
8 |
+
payload: Optional[dict] = None
|
9 |
+
|
10 |
+
|
11 |
+
# Create Portal Request
|
12 |
+
class CreatePortalRequest(BaseModel):
|
13 |
+
name: str = Field(
|
14 |
+
..., max_length=50, description="Name of the portal, e.g., Android or MikroTik"
|
15 |
+
)
|
16 |
+
description: str = Field(
|
17 |
+
..., max_length=255, description="Description of the portal"
|
18 |
+
)
|
19 |
+
url: HttpUrl = Field(..., description="URL of the portal")
|
20 |
+
|
21 |
+
class Config:
|
22 |
+
schema_extra = {
|
23 |
+
"example": {
|
24 |
+
"name": "Android",
|
25 |
+
"description": "Official Android developer portal",
|
26 |
+
"url": "https://developer.android.com",
|
27 |
+
}
|
28 |
+
}
|
29 |
+
|
30 |
+
|
31 |
+
# Update Portal Request
|
32 |
+
class UpdatePortalRequest(BaseModel):
|
33 |
+
description: Optional[str] = Field(
|
34 |
+
None, max_length=255, description="Updated description of the portal"
|
35 |
+
)
|
36 |
+
url: Optional[HttpUrl] = Field(None, description="Updated URL of the portal")
|
37 |
+
|
38 |
+
|
39 |
+
# Portal Response
|
40 |
+
class PortalResponse(BaseModel):
|
41 |
+
id: int
|
42 |
+
name: str
|
43 |
+
description: str
|
44 |
+
url: HttpUrl
|
45 |
+
|
46 |
+
class Config:
|
47 |
+
orm_mode = True
|
48 |
+
|
49 |
+
|
50 |
+
# List of Portals Response
|
51 |
+
class PortalListResponse(BaseModel):
|
52 |
+
portals: List[PortalResponse]
|
53 |
+
total_count: int
|
54 |
+
|
55 |
+
class Config:
|
56 |
+
orm_mode = True
|
App/Subscriptions/BackgroundTask.py
ADDED
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import asyncio
|
2 |
+
from datetime import datetime
|
3 |
+
from .Model import Subscription
|
4 |
+
|
5 |
+
|
6 |
+
async def deactivate_expired_subscriptions():
|
7 |
+
while True:
|
8 |
+
# Find active subscriptions that have expired
|
9 |
+
expired_subscriptions = await Subscription.filter(
|
10 |
+
active=True, expiration_time__lt=datetime.now()
|
11 |
+
)
|
12 |
+
|
13 |
+
# Deactivate all expired subscriptions
|
14 |
+
for subscription in expired_subscriptions:
|
15 |
+
subscription.active = False
|
16 |
+
await subscription.save()
|
17 |
+
|
18 |
+
# Deactivate the user if he has no plans
|
19 |
+
|
20 |
+
# Run this check every hour (or adjust as needed)
|
21 |
+
await asyncio.sleep(60) # 1 hour
|
App/Subscriptions/Model.py
ADDED
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from tortoise import fields
|
2 |
+
from tortoise.models import Model
|
3 |
+
import datetime
|
4 |
+
from App.Users.Model import User
|
5 |
+
from App.Plans.Model import Plan
|
6 |
+
|
7 |
+
|
8 |
+
class Subscription(Model):
|
9 |
+
id = fields.UUIDField(pk=True)
|
10 |
+
user = fields.ForeignKeyField("models.User", related_name="subscriptions")
|
11 |
+
plan = fields.ForeignKeyField(
|
12 |
+
"models.Plan",
|
13 |
+
related_name="subscriptions",
|
14 |
+
null=True,
|
15 |
+
description="Plan associated with the subscription",
|
16 |
+
)
|
17 |
+
active = fields.BooleanField(default=True)
|
18 |
+
duration = fields.IntField(description="Duration in hours")
|
19 |
+
download_mb = fields.FloatField(
|
20 |
+
default=0.0, description="Download usage in megabytes"
|
21 |
+
)
|
22 |
+
upload_mb = fields.FloatField(default=0.0, description="Upload usage in megabytes")
|
23 |
+
created_time = fields.DatetimeField(auto_now_add=True)
|
24 |
+
expiration_time = fields.DatetimeField(null=True)
|
25 |
+
|
26 |
+
class Meta:
|
27 |
+
table = "subscriptions"
|
28 |
+
|
29 |
+
async def save(self, *args, **kwargs):
|
30 |
+
# Calculate expiration_time if it's not set and we have created_time and duration
|
31 |
+
if not self.expiration_time and self.created_time and self.duration:
|
32 |
+
self.expiration_time = self.created_time + datetime.timedelta(
|
33 |
+
hours=self.duration
|
34 |
+
)
|
35 |
+
await super().save(*args, **kwargs)
|
36 |
+
|
37 |
+
async def time_remaining(self) -> float:
|
38 |
+
"""
|
39 |
+
Calculate the remaining time for the subscription in hours.
|
40 |
+
Returns:
|
41 |
+
float: Number of hours remaining. Returns 0 if subscription has expired.
|
42 |
+
"""
|
43 |
+
if not self.active:
|
44 |
+
return 0.0
|
45 |
+
|
46 |
+
current_time = datetime.datetime.now(self.created_time.tzinfo)
|
47 |
+
|
48 |
+
# If subscription has expired, deactivate it
|
49 |
+
if current_time >= self.expiration_time:
|
50 |
+
self.active = False
|
51 |
+
await self.save()
|
52 |
+
return 0.0
|
53 |
+
|
54 |
+
# Calculate remaining time in hours
|
55 |
+
time_diff = self.expiration_time - current_time
|
56 |
+
remaining_hours = time_diff.total_seconds() / 3600
|
57 |
+
|
58 |
+
return round(remaining_hours, 2)
|
59 |
+
|
60 |
+
async def is_valid(self) -> bool:
|
61 |
+
"""
|
62 |
+
Check if the subscription is still valid (active and not expired).
|
63 |
+
Returns:
|
64 |
+
bool: True if subscription is valid, False otherwise.
|
65 |
+
"""
|
66 |
+
remaining_time = await self.time_remaining()
|
67 |
+
return self.active and remaining_time > 0
|
App/Subscriptions/Schema.py
ADDED
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from pydantic import BaseModel, Field
|
2 |
+
from typing import Optional, List
|
3 |
+
from datetime import datetime
|
4 |
+
|
5 |
+
|
6 |
+
class BaseResponse(BaseModel):
|
7 |
+
code: int
|
8 |
+
message: str
|
9 |
+
payload: Optional[dict] = None
|
10 |
+
|
11 |
+
|
12 |
+
class CreateSubscriptionRequest(BaseModel):
|
13 |
+
user_id: str = Field(..., description="ID of the user to create subscription for")
|
14 |
+
plan_id: str = Field(..., description="ID of the plan to base the subscription on")
|
15 |
+
|
16 |
+
|
17 |
+
class UpdateUsageRequest(BaseModel):
|
18 |
+
download_mb: float = Field(0.0, ge=0, description="Download usage in megabytes")
|
19 |
+
upload_mb: float = Field(0.0, ge=0, description="Upload usage in megabytes")
|
20 |
+
|
21 |
+
|
22 |
+
class SubscriptionResponse(BaseModel):
|
23 |
+
id: str
|
24 |
+
user_id: str
|
25 |
+
plan_id: Optional[str]
|
26 |
+
active: bool
|
27 |
+
duration: int
|
28 |
+
download_mb: float
|
29 |
+
upload_mb: float
|
30 |
+
remaining_hours: float
|
31 |
+
created_time: datetime
|
32 |
+
expiration_time: datetime
|
33 |
+
|
34 |
+
class Config:
|
35 |
+
from_attributes = True
|
36 |
+
|
37 |
+
|
38 |
+
class SubscriptionListResponse(BaseModel):
|
39 |
+
subscriptions: List[SubscriptionResponse]
|
40 |
+
total_count: int
|
41 |
+
|
42 |
+
class Config:
|
43 |
+
from_attributes = True
|
App/Subscriptions/SubscriptionRoutes.py
ADDED
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
from fastapi import APIRouter, HTTPException, status, Depends
|
2 |
+
from typing import List
|
3 |
+
from datetime import datetime
|
4 |
+
from .Model import Subscription
|
5 |
+
from App.Users.Model import User
|
6 |
+
from App.Plans.Model import Plan
|
7 |
+
from .Schema import (
|
8 |
+
CreateSubscriptionRequest,
|
9 |
+
SubscriptionResponse,
|
10 |
+
BaseResponse,
|
11 |
+
UpdateUsageRequest,
|
12 |
+
SubscriptionListResponse,
|
13 |
+
)
|
14 |
+
|
15 |
+
subscription_router = APIRouter(tags=["Subscriptions"])
|
16 |
+
|
17 |
+
|
18 |
+
@subscription_router.post("/subscription/create", response_model=BaseResponse)
|
19 |
+
async def create_subscription(request: CreateSubscriptionRequest):
|
20 |
+
# Check if user exists
|
21 |
+
user = await User.get_or_none(id=request.user_id)
|
22 |
+
if not user:
|
23 |
+
raise HTTPException(
|
24 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
25 |
+
)
|
26 |
+
|
27 |
+
# Check if plan exists
|
28 |
+
plan = await Plan.get_or_none(id=request.plan_id)
|
29 |
+
if not plan:
|
30 |
+
raise HTTPException(
|
31 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Plan not found"
|
32 |
+
)
|
33 |
+
|
34 |
+
# Create new subscription based on plan details
|
35 |
+
expiration_time = datetime.datetime.now() + datetime.timedelta(hours=plan.duration)
|
36 |
+
subscription = await Subscription.create(
|
37 |
+
user=user,
|
38 |
+
plan=plan,
|
39 |
+
duration=plan.duration,
|
40 |
+
download_mb=plan.download_speed * 1024, # Converting Mbps to MB
|
41 |
+
upload_mb=plan.upload_speed * 1024, # Converting Mbps to MB
|
42 |
+
expiration_time=expiration_time,
|
43 |
+
active=True,
|
44 |
+
)
|
45 |
+
await subscription.save()
|
46 |
+
|
47 |
+
return BaseResponse(
|
48 |
+
code=200,
|
49 |
+
message="Subscription created successfully",
|
50 |
+
payload={"subscription_id": str(subscription.id)},
|
51 |
+
)
|
52 |
+
|
53 |
+
|
54 |
+
@subscription_router.get(
|
55 |
+
"/subscription/{subscription_id}", response_model=SubscriptionResponse
|
56 |
+
)
|
57 |
+
async def get_subscription(subscription_id: str):
|
58 |
+
subscription = await Subscription.get_or_none(id=subscription_id)
|
59 |
+
if not subscription:
|
60 |
+
raise HTTPException(
|
61 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found"
|
62 |
+
)
|
63 |
+
|
64 |
+
remaining_hours = await subscription.time_remaining()
|
65 |
+
|
66 |
+
return SubscriptionResponse(
|
67 |
+
id=str(subscription.id),
|
68 |
+
user_id=subscription.user_id,
|
69 |
+
plan_id=subscription.plan_id,
|
70 |
+
active=subscription.active,
|
71 |
+
duration=subscription.duration,
|
72 |
+
download_mb=subscription.download_mb,
|
73 |
+
upload_mb=subscription.upload_mb,
|
74 |
+
remaining_hours=remaining_hours,
|
75 |
+
created_time=subscription.created_time,
|
76 |
+
expiration_time=subscription.expiration_time,
|
77 |
+
)
|
78 |
+
|
79 |
+
|
80 |
+
@subscription_router.get(
|
81 |
+
"/subscription/user/{user_id}", response_model=List[SubscriptionResponse]
|
82 |
+
)
|
83 |
+
async def get_user_subscriptions(user_id: str):
|
84 |
+
user = await User.get_or_none(id=user_id)
|
85 |
+
if not user:
|
86 |
+
raise HTTPException(
|
87 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
|
88 |
+
)
|
89 |
+
|
90 |
+
subscriptions = await Subscription.filter(user_id=user_id)
|
91 |
+
result = []
|
92 |
+
|
93 |
+
for subscription in subscriptions:
|
94 |
+
remaining_hours = await subscription.time_remaining()
|
95 |
+
result.append(
|
96 |
+
SubscriptionResponse(
|
97 |
+
id=str(subscription.id),
|
98 |
+
user_id=subscription.user_id,
|
99 |
+
plan_id=subscription.plan_id,
|
100 |
+
active=subscription.active,
|
101 |
+
duration=subscription.duration,
|
102 |
+
download_mb=subscription.download_mb,
|
103 |
+
upload_mb=subscription.upload_mb,
|
104 |
+
remaining_hours=remaining_hours,
|
105 |
+
created_time=subscription.created_time,
|
106 |
+
expiration_time=subscription.expiration_time,
|
107 |
+
)
|
108 |
+
)
|
109 |
+
|
110 |
+
return result
|
111 |
+
|
112 |
+
|
113 |
+
@subscription_router.put(
|
114 |
+
"/subscription/{subscription_id}/update-usage", response_model=BaseResponse
|
115 |
+
)
|
116 |
+
async def update_usage(subscription_id: str, request: UpdateUsageRequest):
|
117 |
+
subscription = await Subscription.get_or_none(id=subscription_id)
|
118 |
+
if not subscription:
|
119 |
+
raise HTTPException(
|
120 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found"
|
121 |
+
)
|
122 |
+
|
123 |
+
if not await subscription.is_valid():
|
124 |
+
raise HTTPException(
|
125 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
126 |
+
detail="Subscription is not active or has expired",
|
127 |
+
)
|
128 |
+
|
129 |
+
subscription.download_mb += request.download_mb
|
130 |
+
subscription.upload_mb += request.upload_mb
|
131 |
+
await subscription.save()
|
132 |
+
|
133 |
+
return BaseResponse(
|
134 |
+
code=200,
|
135 |
+
message="Usage updated successfully",
|
136 |
+
payload={
|
137 |
+
"total_download_mb": subscription.download_mb,
|
138 |
+
"total_upload_mb": subscription.upload_mb,
|
139 |
+
},
|
140 |
+
)
|
141 |
+
|
142 |
+
|
143 |
+
@subscription_router.post(
|
144 |
+
"/subscription/{subscription_id}/deactivate", response_model=BaseResponse
|
145 |
+
)
|
146 |
+
async def deactivate_subscription(subscription_id: str):
|
147 |
+
subscription = await Subscription.get_or_none(id=subscription_id)
|
148 |
+
if not subscription:
|
149 |
+
raise HTTPException(
|
150 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Subscription not found"
|
151 |
+
)
|
152 |
+
|
153 |
+
subscription.active = False
|
154 |
+
await subscription.save()
|
155 |
+
|
156 |
+
return BaseResponse(code=200, message="Subscription deactivated successfully")
|
App/Users/Model.py
CHANGED
@@ -1,76 +1,75 @@
|
|
1 |
-
import
|
2 |
-
import uuid
|
3 |
-
from tortoise import fields, Tortoise
|
4 |
-
from tortoise.models import Model
|
5 |
from passlib.context import CryptContext
|
6 |
import datetime
|
|
|
7 |
import random
|
8 |
import string
|
|
|
9 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
10 |
|
|
|
11 |
def generate_short_uuid() -> str:
|
12 |
-
""
|
13 |
-
return ''.join(random.choices(string.ascii_letters + string.digits, k=5))
|
14 |
|
15 |
|
16 |
-
class User(Model):
|
17 |
id = fields.CharField(primary_key=True, max_length=5, default=generate_short_uuid)
|
18 |
-
name = fields.CharField(max_length=100
|
19 |
-
email = fields.CharField(max_length=100, db_index=True, unique=True)
|
20 |
password = fields.CharField(max_length=100)
|
21 |
-
phoneNumber = fields.CharField(max_length=
|
22 |
-
account_type = fields.IntField(default=1)
|
23 |
balance = fields.DecimalField(max_digits=10, decimal_places=2, default=0.00)
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
updatedAt = fields.DatetimeField(default=datetime.datetime.now)
|
28 |
lastLogin = fields.DatetimeField(default=datetime.datetime.now)
|
29 |
failed_attempts = fields.IntField(default=0)
|
30 |
account_locked = fields.BooleanField(default=False)
|
31 |
-
reset_token = fields.CharField(max_length=
|
32 |
reset_token_expiration = fields.DatetimeField(null=True)
|
33 |
|
34 |
class Meta:
|
35 |
table = "users"
|
36 |
|
|
|
|
|
|
|
37 |
def verify_password(self, plain_password: str) -> bool:
|
38 |
-
if
|
39 |
-
|
|
|
|
|
|
|
|
|
40 |
return False
|
41 |
|
42 |
if pwd_context.verify(plain_password, self.password):
|
43 |
-
self.failed_attempts = 0
|
44 |
self.account_locked = False
|
45 |
-
self.
|
|
|
46 |
return True
|
47 |
else:
|
48 |
self.failed_attempts += 1
|
49 |
if self.failed_attempts >= 5:
|
50 |
self.account_locked = True
|
51 |
-
|
52 |
-
self.save() # Save changes to update the failed attempts count
|
53 |
return False
|
54 |
|
55 |
async def initiate_password_reset(self):
|
56 |
-
|
57 |
-
self.
|
58 |
-
|
|
|
59 |
await self.save()
|
60 |
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
if self.reset_token != reset_token or datetime.datetime.now() > self.reset_token_expiration:
|
67 |
-
print("Invalid or expired reset token.")
|
68 |
return False
|
69 |
-
|
70 |
-
|
71 |
-
self.password = pwd_context.hash(new_password)
|
72 |
-
self.reset_token = None # Clear the token after successful reset
|
73 |
self.reset_token_expiration = None
|
74 |
await self.save()
|
75 |
-
|
76 |
-
return True
|
|
|
1 |
+
from tortoise import fields, models
|
|
|
|
|
|
|
2 |
from passlib.context import CryptContext
|
3 |
import datetime
|
4 |
+
import uuid
|
5 |
import random
|
6 |
import string
|
7 |
+
|
8 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
9 |
|
10 |
+
|
11 |
def generate_short_uuid() -> str:
|
12 |
+
return "".join(random.choices(string.ascii_letters + string.digits, k=5))
|
|
|
13 |
|
14 |
|
15 |
+
class User(models.Model):
|
16 |
id = fields.CharField(primary_key=True, max_length=5, default=generate_short_uuid)
|
17 |
+
name = fields.CharField(max_length=100)
|
|
|
18 |
password = fields.CharField(max_length=100)
|
19 |
+
phoneNumber = fields.CharField(max_length=15, unique=True)
|
|
|
20 |
balance = fields.DecimalField(max_digits=10, decimal_places=2, default=0.00)
|
21 |
+
mac_address = fields.CharField(max_length=17)
|
22 |
+
createdAt = fields.DatetimeField(auto_now_add=True)
|
23 |
+
updatedAt = fields.DatetimeField(auto_now=True)
|
|
|
24 |
lastLogin = fields.DatetimeField(default=datetime.datetime.now)
|
25 |
failed_attempts = fields.IntField(default=0)
|
26 |
account_locked = fields.BooleanField(default=False)
|
27 |
+
reset_token = fields.CharField(max_length=6, null=True, unique=True)
|
28 |
reset_token_expiration = fields.DatetimeField(null=True)
|
29 |
|
30 |
class Meta:
|
31 |
table = "users"
|
32 |
|
33 |
+
def hash_password(self, plain_password: str) -> str:
|
34 |
+
return pwd_context.hash(plain_password)
|
35 |
+
|
36 |
def verify_password(self, plain_password: str) -> bool:
|
37 |
+
if (
|
38 |
+
self.account_locked
|
39 |
+
and datetime.datetime.now()
|
40 |
+
< self.lastLogin + datetime.timedelta(minutes=15)
|
41 |
+
):
|
42 |
+
print("Account is locked due to too many failed attempts. Try again later.")
|
43 |
return False
|
44 |
|
45 |
if pwd_context.verify(plain_password, self.password):
|
46 |
+
self.failed_attempts = 0
|
47 |
self.account_locked = False
|
48 |
+
self.lastLogin = datetime.datetime.now()
|
49 |
+
self.save()
|
50 |
return True
|
51 |
else:
|
52 |
self.failed_attempts += 1
|
53 |
if self.failed_attempts >= 5:
|
54 |
self.account_locked = True
|
55 |
+
self.save()
|
|
|
56 |
return False
|
57 |
|
58 |
async def initiate_password_reset(self):
|
59 |
+
self.reset_token = f"{random.randint(100000, 999999)}"
|
60 |
+
self.reset_token_expiration = datetime.datetime.now() + datetime.timedelta(
|
61 |
+
minutes=15
|
62 |
+
)
|
63 |
await self.save()
|
64 |
|
65 |
+
async def reset_password(self, reset_token: str, new_password: str):
|
66 |
+
if (
|
67 |
+
self.reset_token != reset_token
|
68 |
+
or datetime.datetime.now() > self.reset_token_expiration
|
69 |
+
):
|
|
|
|
|
70 |
return False
|
71 |
+
self.password = self.hash_password(new_password)
|
72 |
+
self.reset_token = None
|
|
|
|
|
73 |
self.reset_token_expiration = None
|
74 |
await self.save()
|
75 |
+
return True
|
|
App/Users/{Schemas.py → Schema.py}
RENAMED
@@ -5,45 +5,46 @@ from passlib.context import CryptContext
|
|
5 |
|
6 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
7 |
|
|
|
|
|
|
|
|
|
8 |
|
9 |
# Register User Request
|
10 |
class RegisterUserRequest(BaseModel):
|
11 |
name: str = Field(..., max_length=100)
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
16 |
def hash_password(self):
|
17 |
self.password = pwd_context.hash(self.password)
|
|
|
18 |
class Config:
|
19 |
schema_extra = {
|
20 |
"example": {
|
21 |
"name": "John Doe",
|
22 |
-
"
|
23 |
-
"
|
24 |
-
"
|
25 |
-
"mac_address": "00:1A:2B:3C:4D:5E"
|
26 |
}
|
27 |
}
|
28 |
|
29 |
|
30 |
-
# Login User Request
|
31 |
class LoginUserRequest(BaseModel):
|
32 |
-
phoneNumber: str = Field(...,
|
33 |
-
password:
|
34 |
-
mac_address: str = Field(...,
|
35 |
|
36 |
-
class Config:
|
37 |
-
schema_extra = {
|
38 |
-
"example": {
|
39 |
-
"phoneNumber": "1234567890",
|
40 |
-
"password": "strongpassword123",
|
41 |
-
"mac_address": "00:1A:2B:3C:4D:5E"
|
42 |
-
}
|
43 |
-
}
|
44 |
|
45 |
-
|
46 |
-
# Access Token Response (used for Login)
|
47 |
class AccessTokenResponse(BaseModel):
|
48 |
access_token: str
|
49 |
token_type: str = "bearer"
|
@@ -55,45 +56,19 @@ class BaseResponse(BaseModel):
|
|
55 |
message: str
|
56 |
payload: Optional[dict] = None
|
57 |
|
58 |
-
# Schemas.py
|
59 |
-
|
60 |
|
61 |
-
|
62 |
-
# Step 1: Forgot Password Request (using phone number)
|
63 |
class ForgotPasswordRequest(BaseModel):
|
64 |
-
phoneNumber: str = Field(...,
|
65 |
-
|
66 |
-
class Config:
|
67 |
-
schema_extra = {
|
68 |
-
"example": {
|
69 |
-
"phoneNumber": "1234567890"
|
70 |
-
}
|
71 |
-
}
|
72 |
|
73 |
|
74 |
-
#
|
75 |
class VerifyResetTokenRequest(BaseModel):
|
76 |
-
phoneNumber: str = Field(...,
|
77 |
-
reset_token: str = Field(..., max_length=6)
|
78 |
-
|
79 |
-
class Config:
|
80 |
-
schema_extra = {
|
81 |
-
"example": {
|
82 |
-
"phoneNumber": "1234567890",
|
83 |
-
"reset_token": "123456"
|
84 |
-
}
|
85 |
-
}
|
86 |
|
87 |
|
88 |
-
#
|
89 |
class ResetPasswordRequest(BaseModel):
|
90 |
-
phoneNumber: str = Field(...,
|
91 |
-
new_password:
|
92 |
-
|
93 |
-
class Config:
|
94 |
-
schema_extra = {
|
95 |
-
"example": {
|
96 |
-
"phoneNumber": "1234567890",
|
97 |
-
"new_password": "newstrongpassword123"
|
98 |
-
}
|
99 |
-
}
|
|
|
5 |
|
6 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
7 |
|
8 |
+
# Constants for phone and MAC address patterns
|
9 |
+
PHONE_PATTERN = r"^(?:\+255|0)\d{9}$"
|
10 |
+
MAC_PATTERN = r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"
|
11 |
+
|
12 |
|
13 |
# Register User Request
|
14 |
class RegisterUserRequest(BaseModel):
|
15 |
name: str = Field(..., max_length=100)
|
16 |
+
password: str = Field(..., max_length=100)
|
17 |
+
phoneNumber: str = Field(
|
18 |
+
...,
|
19 |
+
pattern=PHONE_PATTERN,
|
20 |
+
description="Tanzanian phone number starting with +255 or 0 followed by 9 digits",
|
21 |
+
)
|
22 |
+
mac_address: str = Field(
|
23 |
+
..., pattern=MAC_PATTERN, description="MAC address in standard format"
|
24 |
+
)
|
25 |
+
|
26 |
def hash_password(self):
|
27 |
self.password = pwd_context.hash(self.password)
|
28 |
+
|
29 |
class Config:
|
30 |
schema_extra = {
|
31 |
"example": {
|
32 |
"name": "John Doe",
|
33 |
+
"password": "StrongPassword1!",
|
34 |
+
"phoneNumber": "+255123456789",
|
35 |
+
"mac_address": "00:1A:2B:3C:4D:5E",
|
|
|
36 |
}
|
37 |
}
|
38 |
|
39 |
|
40 |
+
# Login User Request
|
41 |
class LoginUserRequest(BaseModel):
|
42 |
+
phoneNumber: str = Field(..., pattern=PHONE_PATTERN)
|
43 |
+
password: str
|
44 |
+
mac_address: str = Field(..., pattern=MAC_PATTERN)
|
45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
46 |
|
47 |
+
# Access Token Response
|
|
|
48 |
class AccessTokenResponse(BaseModel):
|
49 |
access_token: str
|
50 |
token_type: str = "bearer"
|
|
|
56 |
message: str
|
57 |
payload: Optional[dict] = None
|
58 |
|
|
|
|
|
59 |
|
60 |
+
# Forgot Password Request
|
|
|
61 |
class ForgotPasswordRequest(BaseModel):
|
62 |
+
phoneNumber: str = Field(..., pattern=PHONE_PATTERN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
63 |
|
64 |
|
65 |
+
# Verify Reset Token Request
|
66 |
class VerifyResetTokenRequest(BaseModel):
|
67 |
+
phoneNumber: str = Field(..., pattern=PHONE_PATTERN)
|
68 |
+
reset_token: str = Field(..., max_length=6)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
69 |
|
70 |
|
71 |
+
# Reset Password Request
|
72 |
class ResetPasswordRequest(BaseModel):
|
73 |
+
phoneNumber: str = Field(..., pattern=PHONE_PATTERN)
|
74 |
+
new_password: str = Field(..., max_length=100)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
App/Users/UserRoutes.py
CHANGED
@@ -1,105 +1,102 @@
|
|
1 |
from fastapi import APIRouter, HTTPException, status
|
2 |
-
from .
|
3 |
-
RegisterUserRequest,
|
4 |
-
|
|
|
|
|
|
|
|
|
|
|
5 |
)
|
6 |
from .Model import User
|
7 |
from jose import jwt
|
8 |
-
from datetime import datetime, timedelta
|
9 |
-
import random
|
10 |
-
from passlib.context import CryptContext
|
11 |
|
12 |
-
#
|
13 |
SECRET_KEY = "your_secret_key_here"
|
14 |
ALGORITHM = "HS256"
|
15 |
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
16 |
-
PASSWORD_RESET_EXPIRE_MINUTES = 15 # Reset token valid for 15 minutes
|
17 |
-
|
18 |
-
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
19 |
user_router = APIRouter(tags=["User"])
|
20 |
|
21 |
-
|
22 |
def create_access_token(data: dict, expires_delta: timedelta = None):
|
23 |
to_encode = data.copy()
|
24 |
-
expire = datetime.utcnow() + (
|
|
|
|
|
25 |
to_encode.update({"exp": expire})
|
26 |
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
27 |
|
28 |
-
|
29 |
-
@user_router.post(
|
30 |
-
|
31 |
-
|
|
|
|
|
32 |
if existing_user:
|
33 |
-
raise HTTPException(
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
|
|
|
|
|
|
38 |
|
39 |
-
|
40 |
-
@user_router.post(
|
41 |
-
|
42 |
-
|
43 |
-
|
|
|
|
|
44 |
access_token = create_access_token(data={"sub": db_user.phoneNumber})
|
45 |
return AccessTokenResponse(access_token=access_token, token_type="bearer")
|
46 |
-
|
47 |
raise HTTPException(
|
48 |
-
status_code=status.HTTP_401_UNAUTHORIZED,
|
49 |
-
detail="Invalid credentials",
|
50 |
-
headers={"WWW-Authenticate": "Bearer"},
|
51 |
)
|
52 |
|
53 |
-
|
54 |
-
@user_router.post(
|
|
|
|
|
55 |
async def forgot_password(request: ForgotPasswordRequest):
|
56 |
user = await User.filter(phoneNumber=request.phoneNumber).first()
|
57 |
if not user:
|
58 |
-
raise HTTPException(
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
# Store reset token and expiration in the user record
|
65 |
-
user.reset_token = reset_token
|
66 |
-
user.reset_token_expiration = reset_token_expiration
|
67 |
-
await user.save()
|
68 |
|
69 |
-
# In production, send this token via SMS to the user's phone number
|
70 |
-
print(f"Password reset token for {request.phoneNumber}: {reset_token}")
|
71 |
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
|
|
76 |
async def verify_reset_token(request: VerifyResetTokenRequest):
|
77 |
-
user = await User.filter(
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
|
|
89 |
async def reset_password(request: ResetPasswordRequest):
|
90 |
user = await User.filter(phoneNumber=request.phoneNumber).first()
|
91 |
if not user:
|
92 |
-
raise HTTPException(
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
# Update the user's password and clear the reset token
|
100 |
-
user.password = pwd_context.hash(request.new_password)
|
101 |
-
user.reset_token = None
|
102 |
-
user.reset_token_expiration = None
|
103 |
-
await user.save()
|
104 |
-
|
105 |
return BaseResponse(code=200, message="Password has been reset successfully.")
|
|
|
1 |
from fastapi import APIRouter, HTTPException, status
|
2 |
+
from .Schema import (
|
3 |
+
RegisterUserRequest,
|
4 |
+
LoginUserRequest,
|
5 |
+
AccessTokenResponse,
|
6 |
+
ForgotPasswordRequest,
|
7 |
+
VerifyResetTokenRequest,
|
8 |
+
ResetPasswordRequest,
|
9 |
+
BaseResponse,
|
10 |
)
|
11 |
from .Model import User
|
12 |
from jose import jwt
|
13 |
+
from datetime import datetime, timedelta
|
|
|
|
|
14 |
|
15 |
+
# JWT Configurations
|
16 |
SECRET_KEY = "your_secret_key_here"
|
17 |
ALGORITHM = "HS256"
|
18 |
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
|
|
|
|
|
|
19 |
user_router = APIRouter(tags=["User"])
|
20 |
|
21 |
+
|
22 |
def create_access_token(data: dict, expires_delta: timedelta = None):
|
23 |
to_encode = data.copy()
|
24 |
+
expire = datetime.utcnow() + (
|
25 |
+
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
26 |
+
)
|
27 |
to_encode.update({"exp": expire})
|
28 |
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
29 |
|
30 |
+
|
31 |
+
@user_router.post(
|
32 |
+
"/user/register", response_model=BaseResponse, status_code=status.HTTP_201_CREATED
|
33 |
+
)
|
34 |
+
async def register_user(request: RegisterUserRequest):
|
35 |
+
existing_user = await User.filter(phoneNumber=request.phoneNumber).first()
|
36 |
if existing_user:
|
37 |
+
raise HTTPException(
|
38 |
+
status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists."
|
39 |
+
)
|
40 |
+
request.hash_password()
|
41 |
+
new_user = await User.create(**request.dict())
|
42 |
+
return BaseResponse(
|
43 |
+
code=200, message="User created successfully", payload={"user_id": new_user.id}
|
44 |
+
)
|
45 |
|
46 |
+
|
47 |
+
@user_router.post(
|
48 |
+
"/user/login", response_model=AccessTokenResponse, status_code=status.HTTP_200_OK
|
49 |
+
)
|
50 |
+
async def login_user(request: LoginUserRequest):
|
51 |
+
db_user = await User.filter(phoneNumber=request.phoneNumber).first()
|
52 |
+
if db_user and db_user.verify_password(request.password):
|
53 |
access_token = create_access_token(data={"sub": db_user.phoneNumber})
|
54 |
return AccessTokenResponse(access_token=access_token, token_type="bearer")
|
|
|
55 |
raise HTTPException(
|
56 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
|
|
|
|
57 |
)
|
58 |
|
59 |
+
|
60 |
+
@user_router.post(
|
61 |
+
"/user/forgot-password", response_model=BaseResponse, status_code=status.HTTP_200_OK
|
62 |
+
)
|
63 |
async def forgot_password(request: ForgotPasswordRequest):
|
64 |
user = await User.filter(phoneNumber=request.phoneNumber).first()
|
65 |
if not user:
|
66 |
+
raise HTTPException(
|
67 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="User not found."
|
68 |
+
)
|
69 |
+
await user.initiate_password_reset()
|
70 |
+
return BaseResponse(code=200, message="Password reset token sent to your phone.")
|
|
|
|
|
|
|
|
|
|
|
71 |
|
|
|
|
|
72 |
|
73 |
+
@user_router.post(
|
74 |
+
"/user/verify-reset-token",
|
75 |
+
response_model=BaseResponse,
|
76 |
+
status_code=status.HTTP_200_OK,
|
77 |
+
)
|
78 |
async def verify_reset_token(request: VerifyResetTokenRequest):
|
79 |
+
user = await User.filter(
|
80 |
+
phoneNumber=request.phoneNumber, reset_token=request.reset_token
|
81 |
+
).first()
|
82 |
+
if not user or datetime.utcnow() > user.reset_token_expiration:
|
83 |
+
raise HTTPException(
|
84 |
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired token."
|
85 |
+
)
|
86 |
+
return BaseResponse(code=200, message="Token verified. Proceed to reset password.")
|
87 |
+
|
88 |
+
|
89 |
+
@user_router.post(
|
90 |
+
"/user/reset-password", response_model=BaseResponse, status_code=status.HTTP_200_OK
|
91 |
+
)
|
92 |
async def reset_password(request: ResetPasswordRequest):
|
93 |
user = await User.filter(phoneNumber=request.phoneNumber).first()
|
94 |
if not user:
|
95 |
+
raise HTTPException(
|
96 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="User not found."
|
97 |
+
)
|
98 |
+
if not await user.reset_password(request.reset_token, request.new_password):
|
99 |
+
raise HTTPException(
|
100 |
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired token."
|
101 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
102 |
return BaseResponse(code=200, message="Password has been reset successfully.")
|
App/app.py
CHANGED
@@ -2,7 +2,11 @@ from fastapi import FastAPI
|
|
2 |
from tortoise import Tortoise, run_async
|
3 |
from .Users.UserRoutes import user_router
|
4 |
from .Webhooks.webhookRoute import webhook_router
|
|
|
5 |
from .modelInit import TORTOISE_ORM
|
|
|
|
|
|
|
6 |
|
7 |
app = FastAPI()
|
8 |
|
@@ -20,10 +24,17 @@ async def landing_page():
|
|
20 |
|
21 |
app.include_router(user_router)
|
22 |
app.include_router(webhook_router)
|
|
|
|
|
|
|
|
|
23 |
|
24 |
|
25 |
-
|
26 |
-
|
|
|
|
|
|
|
27 |
|
28 |
|
29 |
# if __name__ == "__main__":
|
|
|
2 |
from tortoise import Tortoise, run_async
|
3 |
from .Users.UserRoutes import user_router
|
4 |
from .Webhooks.webhookRoute import webhook_router
|
5 |
+
from .Subscriptions.SubscriptionRoutes import subscription_router
|
6 |
from .modelInit import TORTOISE_ORM
|
7 |
+
from .Payments.PaymentsRoutes import payment_router
|
8 |
+
from .Plans.PlanRoutes import plan_router
|
9 |
+
from .Portals.PortalRoutes import portal_router
|
10 |
|
11 |
app = FastAPI()
|
12 |
|
|
|
24 |
|
25 |
app.include_router(user_router)
|
26 |
app.include_router(webhook_router)
|
27 |
+
app.include_router(subscription_router)
|
28 |
+
app.include_router(payment_router)
|
29 |
+
app.include_router(plan_router)
|
30 |
+
app.include_router(portal_router)
|
31 |
|
32 |
|
33 |
+
if __name__ == "__main__":
|
34 |
+
import uvicorn
|
35 |
+
|
36 |
+
# Run the FastAPI app located at App.app:app
|
37 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
38 |
|
39 |
|
40 |
# if __name__ == "__main__":
|
App/discovery.py
CHANGED
@@ -1,25 +1,34 @@
|
|
1 |
-
# discover_models.py
|
2 |
-
|
3 |
import importlib
|
4 |
import os
|
5 |
from typing import List
|
6 |
|
7 |
-
|
|
|
8 |
"""
|
9 |
-
Discover target
|
10 |
-
|
11 |
-
:param directory: The root directory to start searching from.
|
12 |
:param target_file: The filename to look for in subdirectories (e.g., "models.py").
|
|
|
|
|
13 |
:return: A list of module paths as strings.
|
14 |
"""
|
|
|
|
|
|
|
|
|
|
|
15 |
model_modules = []
|
16 |
-
|
17 |
# Traverse directory and subdirectories
|
18 |
for root, _, files in os.walk(directory):
|
19 |
if target_file in files:
|
20 |
-
# Construct the module path
|
21 |
relative_path = os.path.relpath(root, directory)
|
22 |
-
module_name =
|
23 |
-
|
24 |
-
|
|
|
|
|
|
|
|
|
25 |
return model_modules
|
|
|
|
|
|
|
1 |
import importlib
|
2 |
import os
|
3 |
from typing import List
|
4 |
|
5 |
+
|
6 |
+
def discover_models(target_file: str, directory: str = None) -> List[str]:
|
7 |
"""
|
8 |
+
Discover and import target files (e.g., models.py) in all directories and subdirectories.
|
9 |
+
|
|
|
10 |
:param target_file: The filename to look for in subdirectories (e.g., "models.py").
|
11 |
+
:param directory: The root directory to start searching from.
|
12 |
+
Defaults to the directory where this script is located.
|
13 |
:return: A list of module paths as strings.
|
14 |
"""
|
15 |
+
if directory is None:
|
16 |
+
directory = os.path.dirname(
|
17 |
+
os.path.abspath(__file__)
|
18 |
+
) # Use current directory of the script
|
19 |
+
|
20 |
model_modules = []
|
21 |
+
|
22 |
# Traverse directory and subdirectories
|
23 |
for root, _, files in os.walk(directory):
|
24 |
if target_file in files:
|
25 |
+
# Construct the module path relative to the given directory
|
26 |
relative_path = os.path.relpath(root, directory)
|
27 |
+
module_name = os.path.join(relative_path, target_file).replace(
|
28 |
+
os.sep, "."
|
29 |
+
) # Correct dot notation
|
30 |
+
module_name = module_name[:-3] # Remove '.py' extension
|
31 |
+
model_modules.append(module_name)
|
32 |
+
|
33 |
+
print("Discovered models:", model_modules)
|
34 |
return model_modules
|
App/modelInit.py
CHANGED
@@ -1,18 +1,39 @@
|
|
|
|
1 |
from App.discovery import discover_models
|
2 |
-
import asyncio
|
3 |
|
|
|
|
|
|
|
|
|
4 |
|
5 |
-
|
6 |
-
|
|
|
7 |
|
8 |
TORTOISE_ORM = {
|
9 |
-
"connections": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
"apps": {
|
11 |
"models": {
|
12 |
-
"models":
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
"default_connection": "default",
|
14 |
}
|
15 |
},
|
16 |
}
|
17 |
-
|
18 |
-
|
|
|
1 |
+
import ssl
|
2 |
from App.discovery import discover_models
|
|
|
3 |
|
4 |
+
# Set up SSL context for secure database connections
|
5 |
+
ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH)
|
6 |
+
ssl_context.check_hostname = True
|
7 |
+
ssl_context.verify_mode = ssl.CERT_REQUIRED
|
8 |
|
9 |
+
# Discover models
|
10 |
+
models = discover_models("Model.py")
|
11 |
+
print("Discovered models:", models)
|
12 |
|
13 |
TORTOISE_ORM = {
|
14 |
+
"connections": {
|
15 |
+
"default": {
|
16 |
+
"engine": "tortoise.backends.asyncpg",
|
17 |
+
"credentials": {
|
18 |
+
"host": "ep-patient-darkness-a5bmmt9r.us-east-2.aws.neon.tech",
|
19 |
+
"port": "5432",
|
20 |
+
"user": "neondb_owner",
|
21 |
+
"password": "l2kE5dbMyqfx",
|
22 |
+
"database": "neondb",
|
23 |
+
"ssl": ssl_context, # Pass the SSL context here
|
24 |
+
},
|
25 |
+
}
|
26 |
+
},
|
27 |
"apps": {
|
28 |
"models": {
|
29 |
+
"models": [
|
30 |
+
"App.Payments.Model",
|
31 |
+
"App.Plans.Model",
|
32 |
+
"App.Portals.Model",
|
33 |
+
"App.Subscriptions.Model",
|
34 |
+
"App.Users.Model",
|
35 |
+
],
|
36 |
"default_connection": "default",
|
37 |
}
|
38 |
},
|
39 |
}
|
|
|
|
App/requirements.txt
ADDED
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
fastapi
|
2 |
+
passlib
|
3 |
+
pydantic[email]
|
4 |
+
uvicorn
|
5 |
+
python-jose
|
6 |
+
bcrypt
|
7 |
+
httpx
|
8 |
+
pytest
|
9 |
+
tortoise-orm
|
App/vercel.json
ADDED
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"builds": [
|
3 |
+
{
|
4 |
+
"src": "App/app.py",
|
5 |
+
"use": "@vercel/python",
|
6 |
+
"runtime": "python3.10"
|
7 |
+
}
|
8 |
+
],
|
9 |
+
"routes": [
|
10 |
+
{
|
11 |
+
"src": "/(.*)",
|
12 |
+
"dest": "App/app.py"
|
13 |
+
}
|
14 |
+
]
|
15 |
+
}
|
Dockerfile
CHANGED
@@ -1,7 +1,18 @@
|
|
1 |
-
|
2 |
-
|
3 |
-
COPY . .
|
4 |
|
5 |
-
|
6 |
-
|
7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Use an official Python image as a base
|
2 |
+
FROM python:3.10-slim
|
|
|
3 |
|
4 |
+
# Set the working directory in the container
|
5 |
+
WORKDIR /app
|
6 |
+
|
7 |
+
# Copy the application files to the container
|
8 |
+
COPY . /app
|
9 |
+
|
10 |
+
# Install dependencies
|
11 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
12 |
+
pip install --no-cache-dir -r requirements.txt
|
13 |
+
|
14 |
+
# Expose the port the app runs on
|
15 |
+
EXPOSE 8000
|
16 |
+
|
17 |
+
# Command to run the FastAPI app using Uvicorn
|
18 |
+
CMD ["uvicorn", "App.app:app", "--host", "0.0.0.0", "--port", "8000"]
|
call.py
ADDED
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import uvicorn
|
2 |
+
|
3 |
+
if __name__ == "__main__":
|
4 |
+
# Run the FastAPI app located at App.app:app
|
5 |
+
uvicorn.run("App.app:app", host="0.0.0.0", port=8000, reload=True)
|
requirements.txt
CHANGED
@@ -2,7 +2,9 @@ fastapi
|
|
2 |
passlib
|
3 |
pydantic[email]
|
4 |
uvicorn
|
5 |
-
jose
|
|
|
6 |
httpx
|
7 |
pytest
|
|
|
8 |
tortoise-orm
|
|
|
2 |
passlib
|
3 |
pydantic[email]
|
4 |
uvicorn
|
5 |
+
python-jose
|
6 |
+
bcrypt
|
7 |
httpx
|
8 |
pytest
|
9 |
+
asyncpg
|
10 |
tortoise-orm
|
vercel.json
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"builds": [
|
3 |
+
{
|
4 |
+
"src": "App/app.py",
|
5 |
+
"use": "@vercel/python"
|
6 |
+
}
|
7 |
+
],
|
8 |
+
"routes": [
|
9 |
+
{
|
10 |
+
"src": "/(.*)",
|
11 |
+
"dest": "App/app.py"
|
12 |
+
}
|
13 |
+
]
|
14 |
+
}
|