Add automatic paypal charging, add ticket page mock
This commit is contained in:
+2
-1
@@ -43,8 +43,9 @@
|
||||
password="Alkohol1"
|
||||
|
||||
[misc]
|
||||
base_url="https://ezgg-lan.de" # In dev mode, this is localhost
|
||||
default_profile_picture="src/elm/assets/img/anon.png"
|
||||
dev_mode_active=true # Supresses E-Mail sending
|
||||
dev_mode_active=true # Supresses E-Mail sending, activates PayPal sandbox API
|
||||
|
||||
[paypal]
|
||||
client_id_sandbox=""
|
||||
|
||||
+3
-2
@@ -48,6 +48,7 @@ Icon.register_single_icon(
|
||||
|
||||
configuration_service = ConfigurationService(from_root("config.toml"))
|
||||
database_service = DatabaseService(configuration_service.get_database_configuration())
|
||||
mailing_service = MailingService(configuration_service)
|
||||
lan_info = configuration_service.get_lan_info()
|
||||
|
||||
def is_mobile(self: Session) -> bool:
|
||||
@@ -56,8 +57,8 @@ def is_mobile(self: Session) -> bool:
|
||||
Session.is_mobile = is_mobile
|
||||
|
||||
async def on_session_start(session: Session) -> None:
|
||||
# Use this line to fake being any user without having to log in
|
||||
if configuration_service.DEV_MODE_ACTIVE:
|
||||
# Use this line to fake being any user without having to log in
|
||||
dev_user = await session[UserService].get_user("Typhus")
|
||||
if not dev_user:
|
||||
logger.fatal("DEV MODE USER DOES NOT EXIST")
|
||||
@@ -78,7 +79,7 @@ app = App(
|
||||
theme=theme,
|
||||
assets_dir=Path(__file__).parent / "assets",
|
||||
build=RootComponent,
|
||||
default_attachments=[LocalData(), configuration_service, database_service, UserService(), LocalDataService(), MailingService(configuration_service), AccountingService(configuration_service.get_paypal_configuration())],
|
||||
default_attachments=[LocalData(), configuration_service, database_service, UserService(), LocalDataService(), mailing_service, AccountingService(configuration_service, mailing_service)],
|
||||
on_app_start=on_app_start,
|
||||
on_session_start=on_session_start,
|
||||
icon=from_root("src/elm/assets/img/favicon.png"),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from typing import Optional, Callable
|
||||
from asyncio import sleep
|
||||
from decimal import Decimal
|
||||
from typing import Callable
|
||||
|
||||
from rio import Component, Row, Column, Color, PointerEventListener, PointerEvent, Rectangle, Text, TextStyle, Icon, event
|
||||
from rio import Component, Row, Column, Color, PointerEventListener, PointerEvent, Rectangle, Text, TextStyle, event
|
||||
|
||||
from elm.types import UserSession
|
||||
from elm.services import AccountingService
|
||||
|
||||
|
||||
class UserNavigationButton(Component):
|
||||
@@ -61,15 +66,31 @@ class UserNavigationButton(Component):
|
||||
|
||||
class UserNavigation(Component):
|
||||
close_navigation: Callable
|
||||
balance: Decimal = Decimal(0)
|
||||
|
||||
@event.on_page_change
|
||||
async def on_page_change(self) -> None:
|
||||
await self.close_navigation()
|
||||
|
||||
async def update_balance(self) -> None:
|
||||
try:
|
||||
balance = await self.session[AccountingService].get_balance(self.session[UserSession].user_name)
|
||||
if balance != self.balance:
|
||||
self.balance = balance
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
await sleep(5)
|
||||
self.session.create_task(self.update_balance())
|
||||
|
||||
@event.on_populate
|
||||
async def on_populate(self) -> None:
|
||||
self.session.create_task(self.update_balance())
|
||||
|
||||
def build(self) -> Component:
|
||||
return Rectangle(
|
||||
content=Column(
|
||||
UserNavigationButton("Guthaben: 0,00 €", "/balance", self.close_navigation),
|
||||
UserNavigationButton(f"Guthaben: {self.session[AccountingService].make_euro_string_from_decimal(self.balance)}", "/balance", self.close_navigation),
|
||||
UserNavigationButton("Mein Profil", "/my-profile", self.close_navigation),
|
||||
UserNavigationButton("Mein Clan", "/my-clans", self.close_navigation),
|
||||
UserNavigationButton("Ausloggen", "/logout", self.close_navigation)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from asyncio import sleep
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, ROUND_DOWN
|
||||
from typing import Optional
|
||||
|
||||
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, TextInput, GuardEvent, DateInput, PointerEventListener, Revealer, Image, NumberInput
|
||||
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, GuardEvent, Revealer, Image, NumberInput
|
||||
from rio.event import on_populate
|
||||
|
||||
from elm.types import UserSession, Transaction
|
||||
from elm.services import UserService, AccountingService
|
||||
from elm.components import ElmButton, AvatarEditBox, AccountInfoBox
|
||||
from elm.services import AccountingService
|
||||
from elm.components import ElmButton
|
||||
|
||||
logger = logging.getLogger(__name__.split(".")[-1])
|
||||
|
||||
@@ -84,13 +85,30 @@ class MyBalancePage(Component):
|
||||
"DE47517624340019856607",
|
||||
f"AUFLADUNG - {self.session[UserSession].user_name}")
|
||||
|
||||
async def check_if_paypal_process_done(self) -> None:
|
||||
await sleep(2)
|
||||
if await self.session[AccountingService].has_user_open_orders(self.session[UserSession].user_name):
|
||||
self.session.create_task(self.check_if_paypal_process_done())
|
||||
else:
|
||||
self.paypal_charge_in_progress = False
|
||||
self.paypal_charge_amount = 0.00
|
||||
self.current_balance = self.session[AccountingService].make_euro_string_from_decimal(
|
||||
await self.session[AccountingService].get_balance(self.session[UserSession].user_name)
|
||||
)
|
||||
self.last_20_transactions = (await self.session[AccountingService].get_transaction_history(self.session[UserSession].user_name))[:20]
|
||||
self.paypal_revealer_open = False
|
||||
|
||||
async def pay_with_paypal(self) -> None:
|
||||
self.paypal_charge_in_progress = True
|
||||
logger.info("Starting PayPal transaction over %s for user %s", f"{self.paypal_charge_amount} €", self.session[UserSession].user_name)
|
||||
amount = Decimal(self.paypal_charge_amount)
|
||||
try:
|
||||
approval_url = await self.session[AccountingService].start_paypal_process(self.session[UserSession].user_name, amount)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return
|
||||
self.session.open_url_in_browser(approval_url)
|
||||
# ToDo: Catch return URL somehow and notify user
|
||||
self.session.create_task(self.check_if_paypal_process_done())
|
||||
|
||||
async def toggle_bank_revealer(self) -> None:
|
||||
self.bank_revealer_open = not self.bank_revealer_open
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from asyncio import sleep
|
||||
|
||||
from rio import Component, Column, Row, Text, Spacer, page, Rectangle, QueryParameter, ProgressCircle
|
||||
from rio.event import on_populate
|
||||
|
||||
from elm.services import AccountingService
|
||||
|
||||
logger = logging.getLogger(__name__.split(".")[-1])
|
||||
|
||||
@page(name="PayPal Return", url_segment="return-paypal")
|
||||
class PayPalReturnPage(Component):
|
||||
token: QueryParameter[str] = "No Value"
|
||||
in_progress: bool = True
|
||||
error_message: str = ""
|
||||
success_message: str = ""
|
||||
|
||||
@on_populate
|
||||
async def on_populate(self) -> None:
|
||||
result = await self.session[AccountingService].finalize_paypal_process(self.token)
|
||||
await sleep(1)
|
||||
if result:
|
||||
self.in_progress = False
|
||||
self.success_message = "Aufladung erfolgreich. Du kannst dieses Fenster schließen."
|
||||
else:
|
||||
self.in_progress = False
|
||||
self.error_message = "Es ist ein Fehler aufgetreten, bitte kontaktiere uns"
|
||||
|
||||
def build(self) -> Component:
|
||||
col_contents = []
|
||||
if self.in_progress:
|
||||
col_contents.append(ProgressCircle(min_size=5, color=self.session.theme.primary_color))
|
||||
col_contents.append(Text("Wir prüfen deine Aufladung", overflow="wrap", justify="center"))
|
||||
else:
|
||||
if self.error_message:
|
||||
col_contents.append(Text(self.error_message, overflow="wrap", justify="center", fill=self.session.theme.danger_color))
|
||||
elif self.success_message:
|
||||
col_contents.append(Text(self.success_message, overflow="wrap", justify="center", fill=self.session.theme.success_color))
|
||||
|
||||
return Row(
|
||||
Rectangle(
|
||||
content=Column(
|
||||
Rectangle(
|
||||
content=Rectangle(
|
||||
content=Text("Paypal Aufladung", margin=0.5, selectable=False, overflow="wrap"),
|
||||
fill=self.session.theme.header_box_background_color,
|
||||
margin=0.4
|
||||
),
|
||||
stroke_width=0.1,
|
||||
stroke_color=self.session.theme.box_border_color,
|
||||
),
|
||||
Column(
|
||||
*col_contents,
|
||||
margin=1,
|
||||
spacing=1
|
||||
),
|
||||
Spacer()
|
||||
),
|
||||
fill=self.session.theme.box_color,
|
||||
stroke_width=0.1,
|
||||
stroke_color=self.session.theme.box_border_color,
|
||||
min_width=1 if self.session.is_mobile() else 25
|
||||
),
|
||||
align_x=0.5,
|
||||
align_y=0.5
|
||||
)
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rio import Component, Column, Row, Text, Spacer, page, Color, TextStyle, Rectangle, TextInput, ProgressBar, Dict
|
||||
from rio.event import on_populate
|
||||
|
||||
from elm.services import ConfigurationService, AccountingService
|
||||
from elm.components import LanCountdownBox, LanInfoBox, LandingPageBoxFull, LandingPageBoxHalf, ElmButton
|
||||
from elm.types import Ticket
|
||||
|
||||
|
||||
@page(name="Tickets", url_segment="tickets")
|
||||
class TicketsPage(Component):
|
||||
sold_tickets_by_category: Dict[str, int] = Dict()
|
||||
|
||||
"""
|
||||
ToDo: Implement conditional ticket buying (check login!)
|
||||
"""
|
||||
|
||||
@on_populate
|
||||
async def on_populate(self) -> None:
|
||||
for ticket_info in self.session[ConfigurationService].get_ticket_info():
|
||||
self.sold_tickets_by_category[ticket_info.category] = len(await Ticket.find_many(Ticket.category == ticket_info.category).to_list())
|
||||
|
||||
def get_available_tickets_by_category(self, category: str, total_tickets: int) -> int:
|
||||
return total_tickets - self.sold_tickets_by_category.get(category, 0)
|
||||
|
||||
def build(self) -> Component:
|
||||
row_col = Column if self.session.is_mobile() else Row
|
||||
ticket_boxes = []
|
||||
for ticket_info in self.session[ConfigurationService].get_ticket_info():
|
||||
ticket_boxes.append(
|
||||
Column(
|
||||
Rectangle(
|
||||
content=Column(
|
||||
Rectangle(
|
||||
content=Rectangle(
|
||||
content=Row(
|
||||
Text(ticket_info.description, margin=0.5, selectable=False, overflow="wrap", grow_x=True),
|
||||
Text(self.session[AccountingService].make_euro_string_from_decimal(ticket_info.price), justify="right", margin_right=0.5, fill=self.session.theme.warning_color)
|
||||
),
|
||||
fill=self.session.theme.header_box_background_color,
|
||||
margin=0.4
|
||||
),
|
||||
stroke_width=0.1,
|
||||
stroke_color=self.session.theme.box_border_color,
|
||||
),
|
||||
Column(
|
||||
Text(ticket_info.additional_info, overflow="wrap", margin_bottom=1),
|
||||
Text("Du besitzt dieses Ticket!", margin_bottom=3, overflow="wrap", fill=self.session.theme.success_color),
|
||||
Row(Text("Verfügbar:", font_size=0.8 if self.session.is_mobile() else 1), Text(f"{self.get_available_tickets_by_category(ticket_info.category, ticket_info.total_tickets)} / {ticket_info.total_tickets}", justify="right", font_size=0.8 if self.session.is_mobile() else 1)),
|
||||
ProgressBar(progress=self.get_available_tickets_by_category(ticket_info.category, ticket_info.total_tickets) / ticket_info.total_tickets, min_height=1),
|
||||
ElmButton(text="Kaufen"),
|
||||
margin=1,
|
||||
spacing=1
|
||||
),
|
||||
Spacer()
|
||||
),
|
||||
fill=self.session.theme.box_color,
|
||||
stroke_width=0.1,
|
||||
stroke_color=self.session.theme.box_border_color
|
||||
),
|
||||
Spacer()
|
||||
)
|
||||
)
|
||||
|
||||
return row_col(
|
||||
*ticket_boxes,
|
||||
spacing=1,
|
||||
margin=1
|
||||
)
|
||||
@@ -7,6 +7,7 @@ import httpx
|
||||
import qrcode
|
||||
|
||||
from elm.types import Transaction, User, PayPalConfiguration
|
||||
from elm.services import MailingService, ConfigurationService
|
||||
|
||||
logger = logging.getLogger(__name__.split(".")[-1])
|
||||
|
||||
@@ -16,16 +17,29 @@ class InsufficientFundsError(Exception):
|
||||
|
||||
|
||||
class AccountingService:
|
||||
def __init__(self, paypal_config: PayPalConfiguration) -> None:
|
||||
self._paypal_config = paypal_config
|
||||
PAYPAL_SANDBOX_URL = "https://api-m.sandbox.paypal.com"
|
||||
PAYPAL_PROD_URL = "https://api-m.paypal.com"
|
||||
|
||||
def __init__(self, configuration_service: ConfigurationService, mailing_service: MailingService) -> None:
|
||||
self._configuration_service = configuration_service
|
||||
self._paypal_config: PayPalConfiguration = configuration_service.get_paypal_configuration()
|
||||
self._pending_paypal_orders: dict[str, tuple[str, Decimal]] = {}
|
||||
self._mailing_service = mailing_service
|
||||
|
||||
async def has_user_open_orders(self, user_name: str) -> bool:
|
||||
for pending_paypal_order in self._pending_paypal_orders.values():
|
||||
if pending_paypal_order[0] == user_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def get_paypal_access_token(self) -> str:
|
||||
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"https://api-m.sandbox.paypal.com/v1/oauth2/token",
|
||||
f"{url}/v1/oauth2/token",
|
||||
auth=(
|
||||
self._paypal_config.client_id_sandbox,
|
||||
self._paypal_config.secret_sandbox,
|
||||
self._paypal_config.client_id_sandbox if self._configuration_service.DEV_MODE_ACTIVE else self._paypal_config.client_id,
|
||||
self._paypal_config.secret_sandbox if self._configuration_service.DEV_MODE_ACTIVE else self._paypal_config.secret,
|
||||
),
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
@@ -41,10 +55,13 @@ class AccountingService:
|
||||
return data["access_token"]
|
||||
|
||||
async def start_paypal_process(self, user_name: str, amount: Decimal) -> str:
|
||||
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
|
||||
return_domain = "http://localhost:8000" if self._configuration_service.DEV_MODE_ACTIVE else self._configuration_service.BASE_URL
|
||||
amount = amount.quantize(Decimal(".01"))
|
||||
async with httpx.AsyncClient() as client:
|
||||
access_token = await self.get_paypal_access_token()
|
||||
response = await client.post(
|
||||
url="https://api-m.sandbox.paypal.com/v2/checkout/orders/",
|
||||
url=f"{url}/v2/checkout/orders/",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}"
|
||||
},
|
||||
@@ -58,16 +75,60 @@ class AccountingService:
|
||||
"value": str(amount)
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"payment_source": {
|
||||
"paypal": {
|
||||
"experience_context": {
|
||||
"return_url": f"{return_domain}/return-paypal",
|
||||
"cancel_url": f"{return_domain}/cancel-paypal",
|
||||
"user_action": "PAY_NOW",
|
||||
"shipping_preference": "NO_SHIPPING"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
approval_url = next(
|
||||
try:
|
||||
payer_action_url = next(
|
||||
link["href"]
|
||||
for link in response.json()["links"]
|
||||
if link["rel"] == "approve"
|
||||
if link["rel"] == "payer-action"
|
||||
)
|
||||
return approval_url
|
||||
except StopIteration:
|
||||
logger.error("No payer action url found: %s", response.text)
|
||||
return "#"
|
||||
|
||||
self._pending_paypal_orders[response.json()["id"]] = (user_name, amount)
|
||||
|
||||
return payer_action_url
|
||||
|
||||
async def finalize_paypal_process(self, order_id: str) -> bool:
|
||||
url = self.PAYPAL_SANDBOX_URL if self._configuration_service.DEV_MODE_ACTIVE else self.PAYPAL_PROD_URL
|
||||
async with httpx.AsyncClient() as client:
|
||||
access_token = await self.get_paypal_access_token()
|
||||
response = await client.get(
|
||||
url=f"{url}/v2/checkout/orders/{order_id}",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
)
|
||||
is_approved = response.json()["status"] == "APPROVED"
|
||||
|
||||
if is_approved:
|
||||
response = await client.post(
|
||||
f"{url}/v2/checkout/orders/{order_id}/capture",
|
||||
headers={
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
is_completed = response.json()["status"] == "COMPLETED"
|
||||
if is_completed:
|
||||
await self.add_balance(self._pending_paypal_orders[order_id][0], self._pending_paypal_orders[order_id][1], "PayPal Aufladung")
|
||||
self._pending_paypal_orders.pop(order_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
async def add_balance(self, user_name: str, balance_to_add: Decimal, title: str) -> Decimal:
|
||||
user = await User.find_one(User.user_name == user_name)
|
||||
@@ -80,7 +141,13 @@ class AccountingService:
|
||||
title=title
|
||||
).save()
|
||||
logger.debug(f"Added balance of {self.make_euro_string_from_decimal(balance_to_add)} to user '{user_name}'")
|
||||
return await self.get_balance(user_name)
|
||||
new_balance = await self.get_balance(user_name)
|
||||
await self._mailing_service.send_email(
|
||||
"Dein Guthaben wurde aufgeladen",
|
||||
self._mailing_service.generate_account_balance_added_mail_body(user, balance_to_add, new_balance),
|
||||
user.user_mail
|
||||
)
|
||||
return new_balance
|
||||
|
||||
async def remove_balance(self, user_name: str, balance_to_remove: Decimal, title: str) -> Decimal:
|
||||
current_balance = await self.get_balance(user_name)
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
import logging
|
||||
import tomllib
|
||||
|
||||
from from_root import from_root
|
||||
|
||||
from elm.types.ConfigurationTypes import MailingServiceConfiguration, LanInfo, ReceiptPrintingConfiguration, DatabaseConfiguration, PayPalConfiguration
|
||||
from elm.types.ConfigurationTypes import MailingServiceConfiguration, LanInfo, ReceiptPrintingConfiguration, DatabaseConfiguration, PayPalConfiguration, TicketInfo
|
||||
|
||||
logger = logging.getLogger(__name__.split(".")[-1])
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@@ -46,6 +47,21 @@ class ConfigurationService:
|
||||
logger.fatal("Error loading DatabaseConfiguration, exiting...")
|
||||
sys.exit(1)
|
||||
|
||||
def get_ticket_info(self) -> tuple[TicketInfo, ...]:
|
||||
try:
|
||||
return tuple([TicketInfo(
|
||||
category=value,
|
||||
total_tickets=self._config["tickets"][value]["total_tickets"],
|
||||
price=Decimal(self._config["tickets"][value]["price"]),
|
||||
description=self._config["tickets"][value]["description"],
|
||||
additional_info=self._config["tickets"][value]["additional_info"]
|
||||
) for value in self._config["tickets"]])
|
||||
except KeyError as e:
|
||||
logger.debug(e)
|
||||
logger.fatal("Error loading ticket configuration, exiting...")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def get_database_configuration(self) -> DatabaseConfiguration:
|
||||
try:
|
||||
return DatabaseConfiguration(
|
||||
@@ -117,3 +133,7 @@ class ConfigurationService:
|
||||
@property
|
||||
def DEFAULT_PROFILE_PICTURE(self) -> bytes:
|
||||
return self._DEFAULT_PROFILE_PICTURE
|
||||
|
||||
@property
|
||||
def BASE_URL(self) -> str:
|
||||
return self._config["misc"]["base_url"]
|
||||
|
||||
@@ -4,7 +4,7 @@ from beanie import init_beanie
|
||||
from pymongo import AsyncMongoClient
|
||||
from pymongo.asynchronous.collection import AsyncCollection
|
||||
|
||||
from elm.types import User, Transaction
|
||||
from elm.types import User, Transaction, Ticket
|
||||
from elm.types.ConfigurationTypes import DatabaseConfiguration
|
||||
|
||||
logger = logging.getLogger(__name__.split(".")[-1])
|
||||
@@ -33,5 +33,5 @@ class DatabaseService:
|
||||
self._users: AsyncCollection = self._database["users"]
|
||||
await init_beanie(
|
||||
database=self._database,
|
||||
document_models=[User, Transaction]
|
||||
document_models=[User, Transaction, Ticket]
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
class NoSuchCategoryError(Exception):
|
||||
pass
|
||||
@@ -44,3 +46,11 @@ class PayPalConfiguration:
|
||||
secret_sandbox: str
|
||||
client_id: str
|
||||
secret: str
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TicketInfo:
|
||||
category: str
|
||||
total_tickets: int
|
||||
price: Decimal
|
||||
description: str
|
||||
additional_info: str
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from beanie import Document, Link
|
||||
|
||||
from elm.types import User
|
||||
|
||||
|
||||
class Ticket(Document):
|
||||
category: str
|
||||
purchase_date: datetime
|
||||
owner: Optional[Link[User]] = None
|
||||
|
||||
class Settings:
|
||||
name = "tickets"
|
||||
@@ -2,3 +2,4 @@ from .User import User
|
||||
from .UserSession import UserSession
|
||||
from .ConfigurationTypes import *
|
||||
from .Transaction import Transaction
|
||||
from .Ticket import Ticket
|
||||
|
||||
Reference in New Issue
Block a user