Working registration, testing payments
Browse files- App/Android/Android.py +46 -3
- App/Android/Schema.py +2 -2
- App/Messages/MessagesRoute.py +1 -4
- App/Payments/Model.py +40 -3
- App/Templates/Payment.md +3 -0
- App/Templates/Registration.md +3 -0
- App/Templates/Templates.py +57 -0
- App/Users/Model.py +188 -3
- App/Users/UserRoutes.py +11 -7
- requirements.txt +3 -1
App/Android/Android.py
CHANGED
@@ -1,5 +1,6 @@
|
|
1 |
import httpx
|
2 |
-
from typing import Optional
|
|
|
3 |
from .Schema import (
|
4 |
RegisterUserRequest,
|
5 |
LoginRequest,
|
@@ -10,48 +11,90 @@ from App.Portals.PortalRoutes import ANDROID
|
|
10 |
from App.Portals.Model import Portal
|
11 |
|
12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
13 |
class AndroidClient:
|
14 |
def __init__(self):
|
15 |
-
self.
|
16 |
-
|
|
|
|
|
|
|
|
|
17 |
|
|
|
18 |
async def register_user(self, request: RegisterUserRequest) -> APIResponse:
|
19 |
"""Register a new user."""
|
20 |
response = await self.client.post("/users/register", json=request.dict())
|
21 |
return APIResponse(**response.json())
|
22 |
|
|
|
23 |
async def login_user(self, request: LoginRequest) -> APIResponse:
|
24 |
"""Login an existing user."""
|
25 |
response = await self.client.post("/login", json=request.dict())
|
26 |
return APIResponse(**response.json())
|
27 |
|
|
|
28 |
async def logout_user(self, phone: str) -> APIResponse:
|
29 |
"""Logout a user by phone."""
|
30 |
response = await self.client.post(f"/logout/{phone}")
|
31 |
return APIResponse(**response.json())
|
32 |
|
|
|
33 |
async def get_active_users(self) -> APIResponse:
|
34 |
"""Retrieve all active users."""
|
35 |
response = await self.client.get("/users/active")
|
36 |
print(response.json())
|
37 |
return APIResponse(**response.json())
|
38 |
|
|
|
39 |
async def set_user_status(self, request: SetUserStatusRequest) -> APIResponse:
|
40 |
"""Enable or disable a user."""
|
41 |
response = await self.client.patch("/user/status", json=request.dict())
|
42 |
return APIResponse(**response.json())
|
43 |
|
|
|
44 |
async def get_users_list(self) -> APIResponse:
|
45 |
"""Retrieve all users (active and inactive)."""
|
46 |
response = await self.client.get("/users")
|
47 |
return APIResponse(**response.json())
|
48 |
|
|
|
49 |
async def get_user_stats(self, phone: Optional[str] = None) -> APIResponse:
|
50 |
"""Retrieve user statistics for a specific user or all users."""
|
51 |
url = f"/user/stats/{phone}" if phone else "/user/stats"
|
52 |
response = await self.client.get(url)
|
53 |
return APIResponse(**response.json())
|
54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
55 |
async def close(self):
|
56 |
"""Close the client session."""
|
57 |
await self.client.aclose()
|
|
|
1 |
import httpx
|
2 |
+
from typing import Optional, Union, List
|
3 |
+
from functools import wraps
|
4 |
from .Schema import (
|
5 |
RegisterUserRequest,
|
6 |
LoginRequest,
|
|
|
11 |
from App.Portals.Model import Portal
|
12 |
|
13 |
|
14 |
+
def require_base_url(func):
|
15 |
+
"""Decorator to set base_url before making a request."""
|
16 |
+
|
17 |
+
@wraps(func)
|
18 |
+
async def wrapper(self, *args, **kwargs):
|
19 |
+
await self.set_base_url()
|
20 |
+
return await func(self, *args, **kwargs)
|
21 |
+
|
22 |
+
return wrapper
|
23 |
+
|
24 |
+
|
25 |
class AndroidClient:
|
26 |
def __init__(self):
|
27 |
+
self.client = httpx.AsyncClient()
|
28 |
+
|
29 |
+
async def set_base_url(self):
|
30 |
+
"""Fetch the base_url from the database."""
|
31 |
+
portal = await Portal.get(name=ANDROID)
|
32 |
+
self.client.base_url = portal.url
|
33 |
|
34 |
+
@require_base_url
|
35 |
async def register_user(self, request: RegisterUserRequest) -> APIResponse:
|
36 |
"""Register a new user."""
|
37 |
response = await self.client.post("/users/register", json=request.dict())
|
38 |
return APIResponse(**response.json())
|
39 |
|
40 |
+
@require_base_url
|
41 |
async def login_user(self, request: LoginRequest) -> APIResponse:
|
42 |
"""Login an existing user."""
|
43 |
response = await self.client.post("/login", json=request.dict())
|
44 |
return APIResponse(**response.json())
|
45 |
|
46 |
+
@require_base_url
|
47 |
async def logout_user(self, phone: str) -> APIResponse:
|
48 |
"""Logout a user by phone."""
|
49 |
response = await self.client.post(f"/logout/{phone}")
|
50 |
return APIResponse(**response.json())
|
51 |
|
52 |
+
@require_base_url
|
53 |
async def get_active_users(self) -> APIResponse:
|
54 |
"""Retrieve all active users."""
|
55 |
response = await self.client.get("/users/active")
|
56 |
print(response.json())
|
57 |
return APIResponse(**response.json())
|
58 |
|
59 |
+
@require_base_url
|
60 |
async def set_user_status(self, request: SetUserStatusRequest) -> APIResponse:
|
61 |
"""Enable or disable a user."""
|
62 |
response = await self.client.patch("/user/status", json=request.dict())
|
63 |
return APIResponse(**response.json())
|
64 |
|
65 |
+
@require_base_url
|
66 |
async def get_users_list(self) -> APIResponse:
|
67 |
"""Retrieve all users (active and inactive)."""
|
68 |
response = await self.client.get("/users")
|
69 |
return APIResponse(**response.json())
|
70 |
|
71 |
+
@require_base_url
|
72 |
async def get_user_stats(self, phone: Optional[str] = None) -> APIResponse:
|
73 |
"""Retrieve user statistics for a specific user or all users."""
|
74 |
url = f"/user/stats/{phone}" if phone else "/user/stats"
|
75 |
response = await self.client.get(url)
|
76 |
return APIResponse(**response.json())
|
77 |
|
78 |
+
@require_base_url
|
79 |
+
async def send_message(
|
80 |
+
self,
|
81 |
+
message: str,
|
82 |
+
phone_numbers: Union[str, List[str]],
|
83 |
+
event: str = "string",
|
84 |
+
) -> APIResponse:
|
85 |
+
"""Send a message to a single phone number or a list of phone numbers."""
|
86 |
+
# Ensure phone_numbers is a list
|
87 |
+
if isinstance(phone_numbers, str):
|
88 |
+
phone_numbers = [phone_numbers]
|
89 |
+
data = {
|
90 |
+
"message": message,
|
91 |
+
"phoneNumbers": phone_numbers,
|
92 |
+
"event": event,
|
93 |
+
}
|
94 |
+
response = await self.client.post("/send_message", json=data)
|
95 |
+
print(response)
|
96 |
+
return response
|
97 |
+
|
98 |
async def close(self):
|
99 |
"""Close the client session."""
|
100 |
await self.client.aclose()
|
App/Android/Schema.py
CHANGED
@@ -6,9 +6,9 @@ from typing import Optional, Dict, Any, List
|
|
6 |
|
7 |
|
8 |
class RegisterUserRequest(BaseModel):
|
9 |
-
|
10 |
password: str
|
11 |
-
profile: Optional[str] = "
|
12 |
|
13 |
|
14 |
class LoginRequest(BaseModel):
|
|
|
6 |
|
7 |
|
8 |
class RegisterUserRequest(BaseModel):
|
9 |
+
phoneNumber: str
|
10 |
password: str
|
11 |
+
profile: Optional[str] = "2mbps_profile"
|
12 |
|
13 |
|
14 |
class LoginRequest(BaseModel):
|
App/Messages/MessagesRoute.py
CHANGED
@@ -48,14 +48,11 @@ async def receive_message(message_data: MessageCreate):
|
|
48 |
user: User = await User.get_or_none(
|
49 |
phoneNumber="+" + parsed_data["phone_number"]
|
50 |
)
|
51 |
-
if not user:
|
52 |
-
raise HTTPException(status_code=404, detail="User not found.")
|
53 |
-
|
54 |
data_plan: Plan = await Plan.get_or_none(
|
55 |
amount=Decimal(parsed_data["amount_received"])
|
56 |
)
|
57 |
payment_details = CreatePaymentRequest(
|
58 |
-
user_id=user.id,
|
59 |
plan_id=str(data_plan.id) if data_plan else None,
|
60 |
amount=Decimal(parsed_data["amount_received"]),
|
61 |
payment_method=PaymentMethod.MPESA,
|
|
|
48 |
user: User = await User.get_or_none(
|
49 |
phoneNumber="+" + parsed_data["phone_number"]
|
50 |
)
|
|
|
|
|
|
|
51 |
data_plan: Plan = await Plan.get_or_none(
|
52 |
amount=Decimal(parsed_data["amount_received"])
|
53 |
)
|
54 |
payment_details = CreatePaymentRequest(
|
55 |
+
user_id=user.id if user else None,
|
56 |
plan_id=str(data_plan.id) if data_plan else None,
|
57 |
amount=Decimal(parsed_data["amount_received"]),
|
58 |
payment_method=PaymentMethod.MPESA,
|
App/Payments/Model.py
CHANGED
@@ -1,3 +1,5 @@
|
|
|
|
|
|
1 |
from tortoise import fields
|
2 |
from tortoise.models import Model
|
3 |
import datetime
|
@@ -6,6 +8,17 @@ 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):
|
@@ -37,6 +50,10 @@ class Payment(Model):
|
|
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 |
|
@@ -57,9 +74,18 @@ class Payment(Model):
|
|
57 |
active=True,
|
58 |
)
|
59 |
self.status = "subscription-created"
|
|
|
|
|
|
|
|
|
|
|
60 |
else:
|
61 |
self.status = "insufficient-funds"
|
62 |
-
|
|
|
|
|
|
|
|
|
63 |
|
64 |
async def create_subscription_or_balance(self):
|
65 |
if self.user and self.plan:
|
@@ -68,6 +94,7 @@ class Payment(Model):
|
|
68 |
hours=self.plan.duration
|
69 |
)
|
70 |
user = await self.user
|
|
|
71 |
# Create the subscription
|
72 |
await Subscription.create(
|
73 |
user=user,
|
@@ -79,10 +106,18 @@ class Payment(Model):
|
|
79 |
active=True,
|
80 |
)
|
81 |
self.status = "subscription-created"
|
|
|
|
|
|
|
|
|
|
|
82 |
else:
|
83 |
self.status = "insufficient-funds"
|
84 |
-
|
85 |
-
|
|
|
|
|
|
|
86 |
elif not self.plan and self.user:
|
87 |
# Await the related user object
|
88 |
user = await self.user
|
@@ -90,3 +125,5 @@ class Payment(Model):
|
|
90 |
await user.save()
|
91 |
self.status = "balance-assigned"
|
92 |
await self.save()
|
|
|
|
|
|
1 |
+
# App/Payments/Model.py
|
2 |
+
|
3 |
from tortoise import fields
|
4 |
from tortoise.models import Model
|
5 |
import datetime
|
|
|
8 |
from App.Plans.Model import Plan
|
9 |
from App.Subscriptions.Model import Subscription
|
10 |
from .Schema import PaymentMethod
|
11 |
+
from App.Android.Android import AndroidClient
|
12 |
+
from App.Templates.Templates import MessageTemplate
|
13 |
+
import logging
|
14 |
+
|
15 |
+
# Configure logging
|
16 |
+
logger = logging.getLogger(__name__)
|
17 |
+
logger.setLevel(logging.INFO)
|
18 |
+
handler = logging.StreamHandler()
|
19 |
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
20 |
+
handler.setFormatter(formatter)
|
21 |
+
logger.addHandler(handler)
|
22 |
|
23 |
|
24 |
class Payment(Model):
|
|
|
50 |
created_time = fields.DatetimeField(auto_now_add=True)
|
51 |
updated_time = fields.DatetimeField(auto_now=True)
|
52 |
|
53 |
+
# Initialize AndroidClient and MessageTemplate as class variables
|
54 |
+
android_client = AndroidClient()
|
55 |
+
message_templates = MessageTemplate()
|
56 |
+
|
57 |
class Meta:
|
58 |
table = "payments"
|
59 |
|
|
|
74 |
active=True,
|
75 |
)
|
76 |
self.status = "subscription-created"
|
77 |
+
await self.save()
|
78 |
+
# Send payment success message with subscription details
|
79 |
+
await self.user.send_payment_success_message(
|
80 |
+
amount=self.amount, plan_name=self.plan.name
|
81 |
+
)
|
82 |
else:
|
83 |
self.status = "insufficient-funds"
|
84 |
+
await self.save()
|
85 |
+
# Send insufficient funds message
|
86 |
+
await self.user.send_insufficient_funds_message(
|
87 |
+
required_amount=self.plan.amount, attempted_amount=self.amount
|
88 |
+
)
|
89 |
|
90 |
async def create_subscription_or_balance(self):
|
91 |
if self.user and self.plan:
|
|
|
94 |
hours=self.plan.duration
|
95 |
)
|
96 |
user = await self.user
|
97 |
+
|
98 |
# Create the subscription
|
99 |
await Subscription.create(
|
100 |
user=user,
|
|
|
106 |
active=True,
|
107 |
)
|
108 |
self.status = "subscription-created"
|
109 |
+
await self.save()
|
110 |
+
# Send payment success message with subscription details
|
111 |
+
await user.send_payment_success_message(
|
112 |
+
amount=self.amount, plan_name=self.plan.name
|
113 |
+
)
|
114 |
else:
|
115 |
self.status = "insufficient-funds"
|
116 |
+
await self.save()
|
117 |
+
# Send insufficient funds message
|
118 |
+
await user.send_insufficient_funds_message(
|
119 |
+
required_amount=self.plan.amount, attempted_amount=self.amount
|
120 |
+
)
|
121 |
elif not self.plan and self.user:
|
122 |
# Await the related user object
|
123 |
user = await self.user
|
|
|
125 |
await user.save()
|
126 |
self.status = "balance-assigned"
|
127 |
await self.save()
|
128 |
+
# Send payment success message with balance assignment
|
129 |
+
await user.send_payment_success_message(amount=self.amount)
|
App/Templates/Payment.md
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
Asante kwa malipo yako ya {{ amount }} TSH kwa huduma ya {{ business_name }}.
|
2 |
+
Muunganisho wako upo tayari na utadumu hadi {{ expiry_date }}.
|
3 |
+
Furahia mtandao wetu!
|
App/Templates/Registration.md
ADDED
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
1 |
+
Karibu {{ user_name }}! Akaunti yako imefanikiwa kusajiliwa.
|
2 |
+
Mambo ni buku buku tu!!!
|
3 |
+
Asante kwa kuchagua {{ business_name }}!
|
App/Templates/Templates.py
ADDED
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
from jinja2 import Environment, FileSystemLoader
|
3 |
+
from decimal import Decimal
|
4 |
+
|
5 |
+
BUSINESS = "Goba Buku Wifi😉"
|
6 |
+
|
7 |
+
|
8 |
+
class MessageTemplate:
|
9 |
+
def __init__(self):
|
10 |
+
# Set the template directory to the same directory as this file
|
11 |
+
template_dir = os.path.dirname(__file__)
|
12 |
+
self.env = Environment(loader=FileSystemLoader(template_dir))
|
13 |
+
|
14 |
+
def render_template(self, template_name: str, **kwargs) -> str:
|
15 |
+
"""Renders a template with provided variables."""
|
16 |
+
template = self.env.get_template(template_name)
|
17 |
+
return template.render(**kwargs)
|
18 |
+
|
19 |
+
def registration_message(self, user_name, business_name=BUSINESS):
|
20 |
+
"""Generates the registration message for SMS."""
|
21 |
+
return self.render_template(
|
22 |
+
"registration.md",
|
23 |
+
user_name=user_name,
|
24 |
+
business_name=business_name,
|
25 |
+
)
|
26 |
+
|
27 |
+
def payment_confirmation_message(self, amount, expiry_date, business_name=BUSINESS):
|
28 |
+
"""Generates the payment confirmation message for SMS."""
|
29 |
+
return self.render_template(
|
30 |
+
"payment_confirmation.md",
|
31 |
+
amount=amount,
|
32 |
+
expiry_date=expiry_date,
|
33 |
+
business_name=business_name,
|
34 |
+
)
|
35 |
+
|
36 |
+
def account_disabled_message(self) -> str:
|
37 |
+
return "Akaunti yako imezimwa. Tafadhali wasiliana na msaada kwa usaidizi."
|
38 |
+
|
39 |
+
def account_enabled_message(self) -> str:
|
40 |
+
return "Akaunti yako imewashwa. Sasa unaweza kufikia akaunti yako."
|
41 |
+
|
42 |
+
def reset_token_message(self, token: str) -> str:
|
43 |
+
return f"Tokeni yako ya kuweka upya nenosiri ni: {token}. Tafadhali tumia tokeni hii kuweka upya nenosiri lako."
|
44 |
+
|
45 |
+
def password_reset_confirmation_message(self) -> str:
|
46 |
+
return "Nenosiri lako limewekwa upya kwa mafanikio. Ikiwa hukufanya kitendo hiki, tafadhali wasiliana na msaada mara moja."
|
47 |
+
|
48 |
+
def subscription_created_message(self, plan_name: str, expiration_time: str) -> str:
|
49 |
+
return f"Habari! Usajili wako kwenye mpango wa {plan_name} umefanikiwa kuundwa. Utaisha tarehe {expiration_time}."
|
50 |
+
|
51 |
+
def insufficient_funds_message(
|
52 |
+
self, required_amount: Decimal, provided_amount: Decimal
|
53 |
+
) -> str:
|
54 |
+
return f"Pesa hazitoshi kwa mpango uliyochagua. Zinazohitajika: {required_amount}, Zilizotolewa: {provided_amount}. Tafadhali ongeza pesa zaidi au chagua mpango tofauti."
|
55 |
+
|
56 |
+
def balance_assigned_message(self, amount: Decimal, new_balance: Decimal) -> str:
|
57 |
+
return f"Habari! Salio la {amount} limeongezwa kwa mafanikio kwenye akaunti yako. Salio lako jipya ni {new_balance}."
|
App/Users/Model.py
CHANGED
@@ -1,21 +1,39 @@
|
|
|
|
|
|
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)
|
@@ -30,16 +48,27 @@ class User(models.Model):
|
|
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 |
-
|
|
|
|
|
43 |
return False
|
44 |
|
45 |
if pwd_context.verify(plain_password, self.password):
|
@@ -52,24 +81,180 @@ class User(models.Model):
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# .Model.py
|
2 |
+
|
3 |
from tortoise import fields, models
|
4 |
from passlib.context import CryptContext
|
5 |
+
from App.Android.Android import AndroidClient
|
6 |
+
from App.Android.Schema import RegisterUserRequest as AndroidRegister
|
7 |
+
from App.Templates.Templates import MessageTemplate
|
8 |
import datetime
|
9 |
import uuid
|
10 |
import random
|
11 |
import string
|
12 |
+
from typing import Optional
|
13 |
+
from decimal import Decimal
|
14 |
+
import logging
|
15 |
+
|
16 |
+
# Configure logging
|
17 |
+
logger = logging.getLogger(__name__)
|
18 |
+
logger.setLevel(logging.INFO)
|
19 |
+
handler = logging.StreamHandler()
|
20 |
+
formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
21 |
+
handler.setFormatter(formatter)
|
22 |
+
logger.addHandler(handler)
|
23 |
|
24 |
+
# Password hashing context
|
25 |
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
26 |
|
27 |
|
28 |
def generate_short_uuid() -> str:
|
29 |
+
"""Generates a random 5-character alphanumeric ID."""
|
30 |
return "".join(random.choices(string.ascii_letters + string.digits, k=5))
|
31 |
|
32 |
|
33 |
class User(models.Model):
|
34 |
id = fields.CharField(primary_key=True, max_length=5, default=generate_short_uuid)
|
35 |
name = fields.CharField(max_length=100)
|
36 |
+
password = fields.CharField(max_length=100) # Stores hashed password
|
37 |
phoneNumber = fields.CharField(max_length=15, unique=True)
|
38 |
balance = fields.DecimalField(max_digits=10, decimal_places=2, default=0.00)
|
39 |
mac_address = fields.CharField(max_length=17)
|
|
|
48 |
class Meta:
|
49 |
table = "users"
|
50 |
|
51 |
+
# Initialize AndroidClient and MessageTemplate as class variables
|
52 |
+
android_client = AndroidClient()
|
53 |
+
message_templates = MessageTemplate()
|
54 |
+
|
55 |
def hash_password(self, plain_password: str) -> str:
|
56 |
+
"""Hashes a plain password."""
|
57 |
return pwd_context.hash(plain_password)
|
58 |
|
59 |
def verify_password(self, plain_password: str) -> bool:
|
60 |
+
"""
|
61 |
+
Verifies a plain password against the hashed password.
|
62 |
+
Handles account locking after multiple failed attempts.
|
63 |
+
"""
|
64 |
if (
|
65 |
self.account_locked
|
66 |
and datetime.datetime.now()
|
67 |
< self.lastLogin + datetime.timedelta(minutes=15)
|
68 |
):
|
69 |
+
logger.warning(
|
70 |
+
f"Account {self.phoneNumber} is locked due to too many failed attempts."
|
71 |
+
)
|
72 |
return False
|
73 |
|
74 |
if pwd_context.verify(plain_password, self.password):
|
|
|
81 |
self.failed_attempts += 1
|
82 |
if self.failed_attempts >= 5:
|
83 |
self.account_locked = True
|
84 |
+
logger.warning(f"Account {self.phoneNumber} has been locked.")
|
85 |
self.save()
|
86 |
return False
|
87 |
|
88 |
async def initiate_password_reset(self):
|
89 |
+
"""Generates a reset token and sends it to the user via message."""
|
90 |
self.reset_token = f"{random.randint(100000, 999999)}"
|
91 |
self.reset_token_expiration = datetime.datetime.now() + datetime.timedelta(
|
92 |
minutes=15
|
93 |
)
|
94 |
await self.save()
|
95 |
+
await self.send_reset_token_message()
|
96 |
|
97 |
+
async def reset_password(self, reset_token: str, new_password: str) -> bool:
|
98 |
+
"""
|
99 |
+
Resets the user's password if the provided token is valid and not expired.
|
100 |
+
Sends a confirmation message upon successful reset.
|
101 |
+
"""
|
102 |
if (
|
103 |
self.reset_token != reset_token
|
104 |
or datetime.datetime.now() > self.reset_token_expiration
|
105 |
):
|
106 |
+
logger.error(f"Invalid or expired reset token for user {self.phoneNumber}.")
|
107 |
return False
|
108 |
self.password = self.hash_password(new_password)
|
109 |
self.reset_token = None
|
110 |
self.reset_token_expiration = None
|
111 |
await self.save()
|
112 |
+
await self.send_password_reset_confirmation()
|
113 |
return True
|
114 |
+
|
115 |
+
@classmethod
|
116 |
+
async def create_user(cls, user_data: dict) -> models.Model:
|
117 |
+
"""
|
118 |
+
Creates a new user, registers with AndroidClient, and sends a welcome message.
|
119 |
+
"""
|
120 |
+
try:
|
121 |
+
# Hash the plain password
|
122 |
+
plain_password = user_data.get("password")
|
123 |
+
if not plain_password:
|
124 |
+
logger.error("Password not provided during user creation.")
|
125 |
+
raise ValueError("Password is required.")
|
126 |
+
hashed_password = pwd_context.hash(plain_password)
|
127 |
+
user_data["password"] = hashed_password
|
128 |
+
|
129 |
+
# Create the user in the database
|
130 |
+
new_user = await cls.create(**user_data)
|
131 |
+
logger.info(f"User {new_user.phoneNumber} created successfully.")
|
132 |
+
|
133 |
+
# Register with AndroidClient
|
134 |
+
register_request = AndroidRegister(
|
135 |
+
password=plain_password, # Assuming AndroidClient expects plain password
|
136 |
+
phoneNumber=new_user.phoneNumber,
|
137 |
+
)
|
138 |
+
try:
|
139 |
+
response = await cls.android_client.register_user(
|
140 |
+
request=register_request
|
141 |
+
)
|
142 |
+
logger.info(
|
143 |
+
f"AndroidClient register_user response for {new_user.phoneNumber}: {response}"
|
144 |
+
)
|
145 |
+
except Exception as e:
|
146 |
+
logger.error(
|
147 |
+
f"Failed to register user with AndroidClient for {new_user.phoneNumber}: {e}"
|
148 |
+
)
|
149 |
+
# Decide whether to rollback user creation or proceed
|
150 |
+
# For simplicity, we'll proceed
|
151 |
+
|
152 |
+
# Send welcome message
|
153 |
+
await new_user.send_welcome_message()
|
154 |
+
|
155 |
+
return new_user
|
156 |
+
except Exception as e:
|
157 |
+
logger.error(f"Error creating user: {e}")
|
158 |
+
raise e
|
159 |
+
|
160 |
+
async def send_welcome_message(self):
|
161 |
+
"""Sends a welcome message to the user."""
|
162 |
+
message = self.message_templates.registration_message(user_name=self.name)
|
163 |
+
try:
|
164 |
+
await self.android_client.send_message(
|
165 |
+
phone_numbers=self.phoneNumber, message=message
|
166 |
+
)
|
167 |
+
logger.info(f"Welcome message sent to {self.phoneNumber}.")
|
168 |
+
except Exception as e:
|
169 |
+
logger.error(f"Failed to send welcome message to {self.phoneNumber}: {e}")
|
170 |
+
|
171 |
+
async def send_reset_token_message(self):
|
172 |
+
"""Sends the reset token to the user's phone number."""
|
173 |
+
message = self.message_templates.reset_token_message(token=self.reset_token)
|
174 |
+
try:
|
175 |
+
await self.android_client.send_message(
|
176 |
+
phone_numbers=self.phoneNumber, message=message
|
177 |
+
)
|
178 |
+
logger.info(f"Reset token sent to {self.phoneNumber}.")
|
179 |
+
except Exception as e:
|
180 |
+
logger.error(f"Failed to send reset token to {self.phoneNumber}: {e}")
|
181 |
+
|
182 |
+
async def send_password_reset_confirmation(self):
|
183 |
+
"""Sends a confirmation message after a successful password reset."""
|
184 |
+
message = self.message_templates.password_reset_confirmation_message()
|
185 |
+
try:
|
186 |
+
await self.android_client.send_message(
|
187 |
+
phone_numbers=self.phoneNumber, message=message
|
188 |
+
)
|
189 |
+
logger.info(f"Password reset confirmation sent to {self.phoneNumber}.")
|
190 |
+
except Exception as e:
|
191 |
+
logger.error(
|
192 |
+
f"Failed to send password reset confirmation to {self.phoneNumber}: {e}"
|
193 |
+
)
|
194 |
+
|
195 |
+
async def toggle_account_status(self):
|
196 |
+
"""Toggles the user's account status and sends a notification message."""
|
197 |
+
self.account_locked = not self.account_locked
|
198 |
+
await self.save()
|
199 |
+
if self.account_locked:
|
200 |
+
message = self.message_templates.account_disabled_message()
|
201 |
+
logger.info(f"Account {self.phoneNumber} has been locked.")
|
202 |
+
else:
|
203 |
+
message = self.message_templates.account_enabled_message()
|
204 |
+
logger.info(f"Account {self.phoneNumber} has been unlocked.")
|
205 |
+
|
206 |
+
try:
|
207 |
+
await self.android_client.send_message(
|
208 |
+
phone_numbers=self.phoneNumber, message=message
|
209 |
+
)
|
210 |
+
logger.info(f"Account status message sent to {self.phoneNumber}.")
|
211 |
+
except Exception as e:
|
212 |
+
logger.error(
|
213 |
+
f"Failed to send account status message to {self.phoneNumber}: {e}"
|
214 |
+
)
|
215 |
+
|
216 |
+
async def send_payment_success_message(
|
217 |
+
self, amount: Decimal, plan_name: Optional[str] = None
|
218 |
+
):
|
219 |
+
"""
|
220 |
+
Sends a payment success message to the user.
|
221 |
+
If a plan is associated, it includes subscription details.
|
222 |
+
"""
|
223 |
+
if plan_name:
|
224 |
+
message = self.message_templates.payment_success_subscription_message(
|
225 |
+
user_name=self.name, amount=amount, plan_name=plan_name
|
226 |
+
)
|
227 |
+
else:
|
228 |
+
message = self.message_templates.payment_success_balance_message(
|
229 |
+
user_name=self.name, amount=amount
|
230 |
+
)
|
231 |
+
try:
|
232 |
+
await self.android_client.send_message(
|
233 |
+
phone_numbers=self.phoneNumber, message=message
|
234 |
+
)
|
235 |
+
logger.info(f"Payment success message sent to {self.phoneNumber}.")
|
236 |
+
except Exception as e:
|
237 |
+
logger.error(
|
238 |
+
f"Failed to send payment success message to {self.phoneNumber}: {e}"
|
239 |
+
)
|
240 |
+
|
241 |
+
async def send_insufficient_funds_message(
|
242 |
+
self, required_amount: Decimal, attempted_amount: Decimal
|
243 |
+
):
|
244 |
+
"""
|
245 |
+
Sends a message to the user indicating insufficient funds for the attempted payment.
|
246 |
+
"""
|
247 |
+
message = self.message_templates.insufficient_funds_message(
|
248 |
+
user_name=self.name,
|
249 |
+
required_amount=required_amount,
|
250 |
+
attempted_amount=attempted_amount,
|
251 |
+
)
|
252 |
+
try:
|
253 |
+
await self.android_client.send_message(
|
254 |
+
phone_numbers=self.phoneNumber, message=message
|
255 |
+
)
|
256 |
+
logger.info(f"Insufficient funds message sent to {self.phoneNumber}.")
|
257 |
+
except Exception as e:
|
258 |
+
logger.error(
|
259 |
+
f"Failed to send insufficient funds message to {self.phoneNumber}: {e}"
|
260 |
+
)
|
App/Users/UserRoutes.py
CHANGED
@@ -14,12 +14,16 @@ from jose import jwt
|
|
14 |
from typing import List
|
15 |
from datetime import datetime, timedelta
|
16 |
from App.Android.Android import AndroidClient
|
|
|
|
|
17 |
|
18 |
# JWT Configurations
|
19 |
SECRET_KEY = "your_secret_key_here"
|
20 |
ALGORITHM = "HS256"
|
21 |
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
22 |
user_router = APIRouter(tags=["User"])
|
|
|
|
|
23 |
|
24 |
|
25 |
def create_access_token(data: dict, expires_delta: timedelta = None):
|
@@ -31,6 +35,11 @@ def create_access_token(data: dict, expires_delta: timedelta = None):
|
|
31 |
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
32 |
|
33 |
|
|
|
|
|
|
|
|
|
|
|
34 |
@user_router.post(
|
35 |
"/user/register", response_model=BaseResponse, status_code=status.HTTP_201_CREATED
|
36 |
)
|
@@ -40,13 +49,8 @@ async def register_user(request: RegisterUserRequest):
|
|
40 |
raise HTTPException(
|
41 |
status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists."
|
42 |
)
|
43 |
-
|
44 |
-
|
45 |
-
print(request.json())
|
46 |
-
response = await client.register_user(request=request)
|
47 |
-
print(response)
|
48 |
-
request.hash_password()
|
49 |
-
new_user = await User.create(**request.dict())
|
50 |
return BaseResponse(
|
51 |
code=200, message="User created successfully", payload={"user_id": new_user.id}
|
52 |
)
|
|
|
14 |
from typing import List
|
15 |
from datetime import datetime, timedelta
|
16 |
from App.Android.Android import AndroidClient
|
17 |
+
from App.Android.Schema import RegisterUserRequest as AndroidRegister
|
18 |
+
from App.Templates.Templates import MessageTemplate
|
19 |
|
20 |
# JWT Configurations
|
21 |
SECRET_KEY = "your_secret_key_here"
|
22 |
ALGORITHM = "HS256"
|
23 |
ACCESS_TOKEN_EXPIRE_MINUTES = 30
|
24 |
user_router = APIRouter(tags=["User"])
|
25 |
+
client = AndroidClient()
|
26 |
+
templates = MessageTemplate()
|
27 |
|
28 |
|
29 |
def create_access_token(data: dict, expires_delta: timedelta = None):
|
|
|
35 |
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
36 |
|
37 |
|
38 |
+
# The user registers
|
39 |
+
# It sends userdetails to the router
|
40 |
+
# It sends the user a message to welcome the user
|
41 |
+
|
42 |
+
|
43 |
@user_router.post(
|
44 |
"/user/register", response_model=BaseResponse, status_code=status.HTTP_201_CREATED
|
45 |
)
|
|
|
49 |
raise HTTPException(
|
50 |
status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists."
|
51 |
)
|
52 |
+
|
53 |
+
new_user = await User.create_user(request.dict())
|
|
|
|
|
|
|
|
|
|
|
54 |
return BaseResponse(
|
55 |
code=200, message="User created successfully", payload={"user_id": new_user.id}
|
56 |
)
|
requirements.txt
CHANGED
@@ -7,4 +7,6 @@ bcrypt
|
|
7 |
httpx
|
8 |
pytest
|
9 |
asyncpg
|
10 |
-
tortoise-orm
|
|
|
|
|
|
7 |
httpx
|
8 |
pytest
|
9 |
asyncpg
|
10 |
+
tortoise-orm
|
11 |
+
markdown2
|
12 |
+
jinja2
|