pyituran 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyituran/__init__.py +5 -0
- pyituran/cmdline.py +95 -0
- pyituran/const.py +32 -0
- pyituran/ituran.py +146 -0
- pyituran/vehicle.py +94 -0
- pyituran-0.1.0.dist-info/METADATA +27 -0
- pyituran-0.1.0.dist-info/RECORD +10 -0
- pyituran-0.1.0.dist-info/WHEEL +5 -0
- pyituran-0.1.0.dist-info/entry_points.txt +3 -0
- pyituran-0.1.0.dist-info/top_level.txt +1 -0
pyituran/__init__.py
ADDED
pyituran/cmdline.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import sys
|
|
6
|
+
from pyituran import Ituran
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def async_main(args=None):
|
|
10
|
+
if args is None:
|
|
11
|
+
args = sys.argv[1:]
|
|
12
|
+
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="Ituran command line tool.\n"
|
|
15
|
+
+ "When executing this tool, please provide a phone number on"
|
|
16
|
+
+ "first use or a mobile ID if already authenticated",
|
|
17
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--id-number",
|
|
21
|
+
action="store",
|
|
22
|
+
dest="id_number",
|
|
23
|
+
default=None,
|
|
24
|
+
help="ID number of the account owner",
|
|
25
|
+
required=True,
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--phone-number",
|
|
29
|
+
action="store",
|
|
30
|
+
dest="phone_number",
|
|
31
|
+
default="",
|
|
32
|
+
help="Phone number of the account owner, required for OTP",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--mobile-id",
|
|
36
|
+
action="store",
|
|
37
|
+
dest="mobile_id",
|
|
38
|
+
default=None,
|
|
39
|
+
help="A unique, 16 hex-digit, ID for this client. "
|
|
40
|
+
+ "Once authenticated via OTP, it must remain constant",
|
|
41
|
+
)
|
|
42
|
+
args = parser.parse_args(args)
|
|
43
|
+
|
|
44
|
+
if not (args.phone_number or args.mobile_id):
|
|
45
|
+
parser.error("Must provide either a phone number or a mobile ID")
|
|
46
|
+
|
|
47
|
+
ituran = Ituran(args.id_number, args.phone_number, args.mobile_id)
|
|
48
|
+
if not await ituran.is_authenticated():
|
|
49
|
+
print("The provided credentials aren't authenticated.")
|
|
50
|
+
response = input(
|
|
51
|
+
f"Would you like to authenticate now with ID '{args.id_number}'"
|
|
52
|
+
+ f" and phone number '{args.phone_number}' (y/n)? "
|
|
53
|
+
)
|
|
54
|
+
if response != "y":
|
|
55
|
+
return
|
|
56
|
+
try:
|
|
57
|
+
await ituran.request_otp()
|
|
58
|
+
except Exception:
|
|
59
|
+
print("Failed requesting OTP, please verify ID and phone number")
|
|
60
|
+
return
|
|
61
|
+
print("OTP request sent.")
|
|
62
|
+
while True:
|
|
63
|
+
otp = input("Please input the received one-time code: ")
|
|
64
|
+
try:
|
|
65
|
+
await ituran.authenticate(otp)
|
|
66
|
+
except:
|
|
67
|
+
print("Incorrect code, please try again.")
|
|
68
|
+
else:
|
|
69
|
+
break
|
|
70
|
+
print(
|
|
71
|
+
"Success!\n"
|
|
72
|
+
+ f"Please keep the following mobile ID for future use: {ituran.mobile_id}"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
vehicles = await ituran.get_vehicles()
|
|
76
|
+
for vehicle in vehicles:
|
|
77
|
+
print(
|
|
78
|
+
f"License plate: {vehicle.license_plate}:\n"
|
|
79
|
+
+ f"\tMake: {vehicle.make}\n"
|
|
80
|
+
+ f"\tModel: {vehicle.model}\n"
|
|
81
|
+
+ f"\tLocation: {vehicle.gps_coordinates}\n"
|
|
82
|
+
+ f"\tAddress: {vehicle.address}\n"
|
|
83
|
+
+ f"\tHeading: {vehicle.heading}\n"
|
|
84
|
+
+ f"\tSpeed: {vehicle.speed}\n"
|
|
85
|
+
+ f"\tMileage: {vehicle.mileage}\n"
|
|
86
|
+
+ f"\tLast update: {vehicle.last_update}\n"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def main(args=None):
|
|
91
|
+
asyncio.run(async_main(args))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main() # pragma: no cover
|
pyituran/const.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Ituran library constants."""
|
|
2
|
+
|
|
3
|
+
DOMAIN = "https://www.ituran.com"
|
|
4
|
+
SOAP_SERVICES = DOMAIN + "/SoapService"
|
|
5
|
+
ACTIVATION_URL = SOAP_SERVICES + "/APPApi.asmx/AppActivation"
|
|
6
|
+
OTP_VERIFICATION_URL = (
|
|
7
|
+
SOAP_SERVICES + "/ituran4all.asmx/AppSerializationRequest"
|
|
8
|
+
)
|
|
9
|
+
ITURAN_GET_VEHICLES_URL = (
|
|
10
|
+
DOMAIN + "/ituranmobileservice/mobileservice.asmx/GetUserPlatforms"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
TEMP_NAMESPACE = "http://tempuri.org/"
|
|
14
|
+
XML_RESPONSE_STATUS = f"{{{TEMP_NAMESPACE}}}ResponseStatus"
|
|
15
|
+
|
|
16
|
+
IMS_NAMESPACE = "http://www.ituran.com/IturanMobileService"
|
|
17
|
+
XML_RETURN_CODE = f"{{{IMS_NAMESPACE}}}ReturnCode"
|
|
18
|
+
XML_ERROR_DESCRIPTION = f"{{{IMS_NAMESPACE}}}ErrorDescription"
|
|
19
|
+
XML_VEHICLES_LIST = f"{{{IMS_NAMESPACE}}}VehList"
|
|
20
|
+
XML_VEHICLE_MODEL = f"{{{IMS_NAMESPACE}}}Model"
|
|
21
|
+
XML_VEHICLE_MAKE = f"{{{IMS_NAMESPACE}}}Make"
|
|
22
|
+
XML_VEHICLE_PLATE = f"{{{IMS_NAMESPACE}}}Plate"
|
|
23
|
+
XML_VEHICLE_LATITUTE = f"{{{IMS_NAMESPACE}}}Lat"
|
|
24
|
+
XML_VEHICLE_LONGITUDE = f"{{{IMS_NAMESPACE}}}Lon"
|
|
25
|
+
XML_VEHICLE_ADDRESS = f"{{{IMS_NAMESPACE}}}Address"
|
|
26
|
+
XML_VEHICLE_SPEED = f"{{{IMS_NAMESPACE}}}Speed"
|
|
27
|
+
XML_VEHICLE_HEADING = f"{{{IMS_NAMESPACE}}}Head"
|
|
28
|
+
XML_VEHICLE_LAST_MILEAGE = f"{{{IMS_NAMESPACE}}}LastMileage"
|
|
29
|
+
XML_VEHICLE_UPDATE_DATE = f"{{{IMS_NAMESPACE}}}Date"
|
|
30
|
+
|
|
31
|
+
ERROR_OK = "ok"
|
|
32
|
+
ERROR_INVALID_CREDENTIALS = "IncorrectUserNameOrPassword"
|
pyituran/ituran.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Module client for the Ituran web service."""
|
|
2
|
+
|
|
3
|
+
import aiohttp
|
|
4
|
+
from aiohttp import FormData
|
|
5
|
+
import logging
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
import uuid
|
|
8
|
+
import xml.etree.ElementTree as ElementTree
|
|
9
|
+
|
|
10
|
+
from pyituran.const import (
|
|
11
|
+
ERROR_INVALID_CREDENTIALS,
|
|
12
|
+
ERROR_OK,
|
|
13
|
+
ACTIVATION_URL,
|
|
14
|
+
ITURAN_GET_VEHICLES_URL,
|
|
15
|
+
OTP_VERIFICATION_URL,
|
|
16
|
+
XML_ERROR_DESCRIPTION,
|
|
17
|
+
XML_RESPONSE_STATUS,
|
|
18
|
+
XML_RETURN_CODE,
|
|
19
|
+
XML_VEHICLES_LIST,
|
|
20
|
+
)
|
|
21
|
+
from pyituran.vehicle import Vehicle
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__package__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Ituran:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
id_number: str,
|
|
31
|
+
phone_number: str,
|
|
32
|
+
mobile_id: Optional[str] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
assert id_number is not None
|
|
35
|
+
assert phone_number is not None
|
|
36
|
+
self.__id_number = id_number
|
|
37
|
+
self.__phone_number = phone_number
|
|
38
|
+
self.__mobile_id = (
|
|
39
|
+
mobile_id if mobile_id is not None else self.__generate_mobile_id()
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
async def is_authenticated(self) -> bool:
|
|
43
|
+
"""Tests if current credentials are valid."""
|
|
44
|
+
try:
|
|
45
|
+
_ = await self.get_vehicles()
|
|
46
|
+
return True
|
|
47
|
+
except Exception as e:
|
|
48
|
+
if len(e.args) > 0 and e.args[0] == ERROR_INVALID_CREDENTIALS:
|
|
49
|
+
return False
|
|
50
|
+
raise
|
|
51
|
+
|
|
52
|
+
async def request_otp(self) -> bool:
|
|
53
|
+
data = FormData()
|
|
54
|
+
data.add_field("UserName", self.__id_number)
|
|
55
|
+
data.add_field("SiebelPassword", self.__phone_number)
|
|
56
|
+
data.add_field("AppId", 49)
|
|
57
|
+
data.add_field("OSType", "Android")
|
|
58
|
+
try:
|
|
59
|
+
async with aiohttp.ClientSession() as session:
|
|
60
|
+
logger.debug("Requesting OTP")
|
|
61
|
+
async with session.post(ACTIVATION_URL, data=data) as response:
|
|
62
|
+
response_data = await response.text()
|
|
63
|
+
logger.debug(f"Got {response.status}: {response_data}")
|
|
64
|
+
assert response.status == 200
|
|
65
|
+
root = ElementTree.fromstring(response_data)
|
|
66
|
+
response_status = root.find(XML_RESPONSE_STATUS)
|
|
67
|
+
assert response_status is not None
|
|
68
|
+
if response_status.text != ERROR_OK:
|
|
69
|
+
raise Exception(response_status.text)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
logging.error(f"Failed requesting OTP: {e}")
|
|
72
|
+
raise
|
|
73
|
+
return True
|
|
74
|
+
|
|
75
|
+
async def authenticate(self, otp: str) -> bool:
|
|
76
|
+
data = FormData()
|
|
77
|
+
data.add_field("UserName", self.__id_number)
|
|
78
|
+
data.add_field("SiebelPassword", self.__phone_number)
|
|
79
|
+
data.add_field("OTPcode", otp)
|
|
80
|
+
data.add_field("AppId", 49)
|
|
81
|
+
data.add_field("MobileId", self.__mobile_id)
|
|
82
|
+
try:
|
|
83
|
+
async with aiohttp.ClientSession() as session:
|
|
84
|
+
logger.debug(f"Authenticating with OTP {otp}")
|
|
85
|
+
async with session.post(
|
|
86
|
+
OTP_VERIFICATION_URL, data=data
|
|
87
|
+
) as response:
|
|
88
|
+
response_data = await response.text()
|
|
89
|
+
logger.debug(f"Got {response.status}: {response_data}")
|
|
90
|
+
root = ElementTree.fromstring(response_data)
|
|
91
|
+
response_status = root.find(XML_RESPONSE_STATUS)
|
|
92
|
+
assert response_status is not None
|
|
93
|
+
if response_status.text != ERROR_OK:
|
|
94
|
+
raise Exception(response_status.text)
|
|
95
|
+
except Exception as e:
|
|
96
|
+
logging.error(f"Failed requesting OTP: {e}")
|
|
97
|
+
raise
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
async def get_vehicles(self) -> List[Vehicle]:
|
|
101
|
+
vehicles: List[Vehicle] = []
|
|
102
|
+
try:
|
|
103
|
+
root = await self.__get_vehicles_xml()
|
|
104
|
+
error = self.__get_error_from_response(root)
|
|
105
|
+
if error is not None:
|
|
106
|
+
raise Exception(error)
|
|
107
|
+
vehicles_list = root.find(XML_VEHICLES_LIST)
|
|
108
|
+
assert vehicles_list is not None
|
|
109
|
+
for vehicle in vehicles_list:
|
|
110
|
+
vehicles.append(Vehicle(vehicle))
|
|
111
|
+
except Exception as e:
|
|
112
|
+
logging.error(f"Failed getting list of vehicles: {e}")
|
|
113
|
+
raise
|
|
114
|
+
return vehicles
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def mobile_id(self):
|
|
118
|
+
return self.__mobile_id
|
|
119
|
+
|
|
120
|
+
async def __get_vehicles_xml(self) -> ElementTree.Element:
|
|
121
|
+
data = FormData()
|
|
122
|
+
data.add_field("UserName", self.__id_number)
|
|
123
|
+
data.add_field("GetAddress", True)
|
|
124
|
+
data.add_field("Password", self.__mobile_id)
|
|
125
|
+
async with aiohttp.ClientSession() as session:
|
|
126
|
+
logger.debug("Getting list of vehicles")
|
|
127
|
+
async with session.post(
|
|
128
|
+
ITURAN_GET_VEHICLES_URL, data=data
|
|
129
|
+
) as response:
|
|
130
|
+
response_data = await response.text()
|
|
131
|
+
assert response.status == 200
|
|
132
|
+
logger.debug(f"Got {response.status}: {response_data}")
|
|
133
|
+
return ElementTree.fromstring(response_data)
|
|
134
|
+
|
|
135
|
+
def __get_error_from_response(
|
|
136
|
+
self, xml: ElementTree.Element
|
|
137
|
+
) -> Optional[str]:
|
|
138
|
+
return_code = xml.find(XML_RETURN_CODE)
|
|
139
|
+
error_description = xml.find(XML_ERROR_DESCRIPTION)
|
|
140
|
+
assert return_code is not None and error_description is not None
|
|
141
|
+
if return_code.text == ERROR_OK.upper():
|
|
142
|
+
return None
|
|
143
|
+
return error_description.text
|
|
144
|
+
|
|
145
|
+
def __generate_mobile_id(self) -> str:
|
|
146
|
+
return uuid.uuid4().hex[:16]
|
pyituran/vehicle.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Class representing an Ituran vehicle."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Tuple
|
|
6
|
+
import xml.etree.ElementTree as ElementTree
|
|
7
|
+
|
|
8
|
+
from pyituran.const import (
|
|
9
|
+
XML_VEHICLE_MODEL,
|
|
10
|
+
XML_VEHICLE_MAKE,
|
|
11
|
+
XML_VEHICLE_PLATE,
|
|
12
|
+
XML_VEHICLE_LATITUTE,
|
|
13
|
+
XML_VEHICLE_LONGITUDE,
|
|
14
|
+
XML_VEHICLE_ADDRESS,
|
|
15
|
+
XML_VEHICLE_SPEED,
|
|
16
|
+
XML_VEHICLE_HEADING,
|
|
17
|
+
XML_VEHICLE_LAST_MILEAGE,
|
|
18
|
+
XML_VEHICLE_UPDATE_DATE,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__package__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Vehicle:
|
|
26
|
+
def __init__(self, xml: ElementTree.Element) -> None:
|
|
27
|
+
self.__make: str = self.__xml_get_field(xml, XML_VEHICLE_MAKE)
|
|
28
|
+
self.__model: str = self.__xml_get_field(xml, XML_VEHICLE_MODEL)
|
|
29
|
+
self.__license_plate: str = self.__xml_get_field(
|
|
30
|
+
xml, XML_VEHICLE_PLATE
|
|
31
|
+
)
|
|
32
|
+
self.__latitue: float = float(
|
|
33
|
+
self.__xml_get_field(xml, XML_VEHICLE_LATITUTE)
|
|
34
|
+
)
|
|
35
|
+
self.__longitude: float = float(
|
|
36
|
+
self.__xml_get_field(xml, XML_VEHICLE_LONGITUDE)
|
|
37
|
+
)
|
|
38
|
+
self.__address: str = self.__xml_get_field(xml, XML_VEHICLE_ADDRESS)
|
|
39
|
+
self.__speed: int = int(self.__xml_get_field(xml, XML_VEHICLE_SPEED))
|
|
40
|
+
self.__heading: int = int(
|
|
41
|
+
self.__xml_get_field(xml, XML_VEHICLE_HEADING)
|
|
42
|
+
)
|
|
43
|
+
self.__mileage: float = float(
|
|
44
|
+
self.__xml_get_field(xml, XML_VEHICLE_LAST_MILEAGE)
|
|
45
|
+
)
|
|
46
|
+
self.__last_update: datetime = datetime.fromisoformat(
|
|
47
|
+
self.__xml_get_field(xml, XML_VEHICLE_UPDATE_DATE)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def make(self) -> str:
|
|
52
|
+
return self.__make
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def model(self) -> str:
|
|
56
|
+
return self.__model
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def license_plate(self) -> str:
|
|
60
|
+
return self.__license_plate
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def gps_coordinates(self) -> Tuple[float, float]:
|
|
64
|
+
return (self.__latitue, self.__longitude)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def address(self) -> str:
|
|
68
|
+
return self.__address
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def heading(self) -> int:
|
|
72
|
+
return self.__heading
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def speed(self) -> int:
|
|
76
|
+
return self.__speed
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def mileage(self) -> float:
|
|
80
|
+
return self.__mileage
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def last_update(self) -> datetime:
|
|
84
|
+
return self.__last_update
|
|
85
|
+
|
|
86
|
+
def __str__(self) -> str:
|
|
87
|
+
return f"{self.make} {self.model} @ {self.gps_coordinates}"
|
|
88
|
+
|
|
89
|
+
def __xml_get_field(self, xml: ElementTree.Element, path: str) -> str:
|
|
90
|
+
element = xml.find(path)
|
|
91
|
+
assert element is not None
|
|
92
|
+
text = element.text
|
|
93
|
+
assert text is not None
|
|
94
|
+
return text
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: pyituran
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A module to interact with Ituran's web service.
|
|
5
|
+
Home-page: https://github.com/shmuelzon/pyituran
|
|
6
|
+
Author: Shmuelzon
|
|
7
|
+
License: MIT
|
|
8
|
+
Download-URL: https://github.com/shmuelzon/pyituran/tarball/0.1.0
|
|
9
|
+
Platform: UNKNOWN
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: aiohttp
|
|
13
|
+
Requires-Dist: asyncio
|
|
14
|
+
Provides-Extra: test
|
|
15
|
+
Requires-Dist: black; extra == "test"
|
|
16
|
+
Requires-Dist: coverage; extra == "test"
|
|
17
|
+
Requires-Dist: flake8; extra == "test"
|
|
18
|
+
Requires-Dist: pep8-naming; extra == "test"
|
|
19
|
+
Requires-Dist: pytest; extra == "test"
|
|
20
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
21
|
+
Requires-Dist: pytest-cov; extra == "test"
|
|
22
|
+
Requires-Dist: setuptools; extra == "test"
|
|
23
|
+
Requires-Dist: wheel; extra == "test"
|
|
24
|
+
|
|
25
|
+
# pyituran
|
|
26
|
+
|
|
27
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyituran/__init__.py,sha256=oBzbaNbraC4gg3BZ_8_hEEXJP2WBXpZPNC0KwiTYfpg,86
|
|
2
|
+
pyituran/cmdline.py,sha256=9R5ha5YHb_EPFWiNo-f_wuW4xWd0PW0vnQuPO1uUpbs,2949
|
|
3
|
+
pyituran/const.py,sha256=JGtj1rpn4d0xcD7iyWoyYcqCXzDChfNVVgkHIr3zous,1265
|
|
4
|
+
pyituran/ituran.py,sha256=EPwb97V797Glh4gqZ7YrxIayRNU-S07ccMiSWRuspAM,5246
|
|
5
|
+
pyituran/vehicle.py,sha256=3moz0HufyKIwXI-MwHoF_iacz9pgEFWa5n48TBZno8w,2592
|
|
6
|
+
pyituran-0.1.0.dist-info/METADATA,sha256=oMBgiT-xRDqkzK9a8O_p3V8ZIDbhUiParg8m568oEdc,789
|
|
7
|
+
pyituran-0.1.0.dist-info/WHEEL,sha256=eOLhNAGa2EW3wWl_TU484h7q1UNgy0JXjjoqKoxAAQc,92
|
|
8
|
+
pyituran-0.1.0.dist-info/entry_points.txt,sha256=VuU9w2Kxniw9SS_pfoPbYEfrmNhRby-8Vd-GXVCwdP8,50
|
|
9
|
+
pyituran-0.1.0.dist-info/top_level.txt,sha256=a_BX035kA9RJcpur2iKFZc6Lcw5i1PExPPTlNi1AWU4,9
|
|
10
|
+
pyituran-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyituran
|