first commit
This commit is contained in:
parent
185e8dc56f
commit
8dc6f21719
|
|
@ -0,0 +1,297 @@
|
|||
frappe.ui.form.on("Incus Container", {
|
||||
refresh: function (frm) {
|
||||
// Кнопка: Создать контейнер
|
||||
if (frm.doc.status === "Creating") {
|
||||
frm.add_custom_button("Создать", function () {
|
||||
// Показываем индикатор загрузки
|
||||
frappe.show_alert({
|
||||
message: "Создание контейнера...",
|
||||
indicator: "blue"
|
||||
});
|
||||
|
||||
frappe.call({
|
||||
method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container",
|
||||
args: {
|
||||
name: frm.doc.container_name,
|
||||
image_alias: frm.doc.image || "alpine/edge",
|
||||
container_type: frm.doc.container_type || "container",
|
||||
wait_completion: true
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
const response = r.message;
|
||||
|
||||
if (response.success) {
|
||||
frappe.msgprint({
|
||||
title: "Успех",
|
||||
indicator: "green",
|
||||
message: response.message
|
||||
});
|
||||
|
||||
// Показываем детали операции если есть
|
||||
if (response.operation_data) {
|
||||
console.log("Operation details:", response.operation_data);
|
||||
}
|
||||
|
||||
// Обновляем статус документа
|
||||
frm.set_value('status', 'Created');
|
||||
frm.save();
|
||||
|
||||
} else {
|
||||
frappe.msgprint({
|
||||
title: "Ошибка создания контейнера",
|
||||
indicator: "red",
|
||||
message: response.message
|
||||
});
|
||||
|
||||
console.error("Error details:", response.error);
|
||||
|
||||
// При ошибке ставим статус Error
|
||||
frm.set_value('status', 'Error');
|
||||
frm.save();
|
||||
}
|
||||
}
|
||||
},
|
||||
error: function(r) {
|
||||
frappe.msgprint({
|
||||
title: "Системная ошибка",
|
||||
indicator: "red",
|
||||
message: "Произошла ошибка при вызове API"
|
||||
});
|
||||
console.error("API call error:", r);
|
||||
}
|
||||
});
|
||||
}, "Управление");
|
||||
}
|
||||
|
||||
// Кнопка: Быстрое создание (без ожидания)
|
||||
if (frm.doc.status === "Creating") {
|
||||
frm.add_custom_button("Создать (быстро)", function () {
|
||||
frappe.show_alert({
|
||||
message: "Отправка запроса на создание контейнера...",
|
||||
indicator: "orange"
|
||||
});
|
||||
|
||||
frappe.call({
|
||||
method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container",
|
||||
args: {
|
||||
name: frm.doc.container_name,
|
||||
image_alias: frm.doc.image || "alpine/edge",
|
||||
container_type: frm.doc.container_type || "container",
|
||||
wait_completion: false
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
const response = r.message;
|
||||
|
||||
if (response.success) {
|
||||
frappe.msgprint({
|
||||
title: "Запрос отправлен",
|
||||
indicator: "blue",
|
||||
message: `${response.message}<br><small>Операция: ${response.operation_url}</small>`
|
||||
});
|
||||
|
||||
console.log("Operation URL:", response.operation_url);
|
||||
|
||||
// Обновляем статус на "В процессе"
|
||||
frm.set_value('status', 'Processing');
|
||||
frm.save();
|
||||
|
||||
} else {
|
||||
frappe.msgprint({
|
||||
title: "Ошибка",
|
||||
indicator: "red",
|
||||
message: response.message
|
||||
});
|
||||
|
||||
console.error("Error details:", response.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, "Управление");
|
||||
}
|
||||
|
||||
// Кнопка: Создать с пользовательскими параметрами
|
||||
frm.add_custom_button("Создать с параметрами", function () {
|
||||
// Создаем диалог для ввода параметров
|
||||
let dialog = new frappe.ui.Dialog({
|
||||
title: 'Создание контейнера',
|
||||
fields: [
|
||||
{
|
||||
label: 'Имя контейнера',
|
||||
fieldname: 'container_name',
|
||||
fieldtype: 'Data',
|
||||
reqd: 1,
|
||||
default: frm.doc.container_name || ''
|
||||
},
|
||||
{
|
||||
label: 'Образ (алиас)',
|
||||
fieldname: 'image_alias',
|
||||
fieldtype: 'Data',
|
||||
reqd: 1,
|
||||
default: frm.doc.image || 'alpine/edge'
|
||||
},
|
||||
{
|
||||
label: 'Тип контейнера',
|
||||
fieldname: 'container_type',
|
||||
fieldtype: 'Select',
|
||||
options: 'container\nvirtual-machine',
|
||||
default: frm.doc.container_type || 'container'
|
||||
},
|
||||
{
|
||||
label: 'Ждать завершения',
|
||||
fieldname: 'wait_completion',
|
||||
fieldtype: 'Check',
|
||||
default: 1
|
||||
}
|
||||
],
|
||||
primary_action_label: 'Создать',
|
||||
primary_action(values) {
|
||||
dialog.hide();
|
||||
|
||||
frappe.show_alert({
|
||||
message: "Создание контейнера...",
|
||||
indicator: "blue"
|
||||
});
|
||||
|
||||
frappe.call({
|
||||
method: "incus_container.incus_container.doctype.incus_container.incus_container.create_container",
|
||||
args: {
|
||||
name: values.container_name,
|
||||
image_alias: values.image_alias,
|
||||
container_type: values.container_type,
|
||||
wait_completion: values.wait_completion
|
||||
},
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
const response = r.message;
|
||||
|
||||
if (response.success) {
|
||||
frappe.msgprint({
|
||||
title: "Успех",
|
||||
indicator: "green",
|
||||
message: response.message
|
||||
});
|
||||
|
||||
// Обновляем поля документа если создавался для него
|
||||
if (values.container_name === frm.doc.container_name) {
|
||||
frm.set_value('image', values.image_alias);
|
||||
frm.set_value('container_type', values.container_type);
|
||||
frm.set_value('status', values.wait_completion ? 'Created' : 'Processing');
|
||||
frm.save();
|
||||
}
|
||||
|
||||
} else {
|
||||
frappe.msgprint({
|
||||
title: "Ошибка",
|
||||
indicator: "red",
|
||||
message: response.message
|
||||
});
|
||||
|
||||
console.error("Error details:", response.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
dialog.show();
|
||||
}, "Управление");
|
||||
|
||||
// Кнопка: Показать список контейнеров
|
||||
frm.add_custom_button("Список контейнеров", function () {
|
||||
frappe.show_alert({
|
||||
message: "Загружаем список контейнеров...",
|
||||
indicator: "blue"
|
||||
});
|
||||
|
||||
frappe.call({
|
||||
method: "incus_container.incus_container.doctype.incus_container.incus_container.list_containers",
|
||||
callback: function (r) {
|
||||
if (r.message) {
|
||||
const response = r.message;
|
||||
|
||||
if (response.success && response.containers) {
|
||||
// Создаем HTML таблицу для отображения
|
||||
let table_html = `
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Статус</th>
|
||||
<th>Тип</th>
|
||||
<th>Архитектура</th>
|
||||
<th>Создан</th>
|
||||
<th>Последнее использование</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
response.containers.forEach(container => {
|
||||
// Определяем цвет статуса
|
||||
let status_color = 'text-muted';
|
||||
if (container.status === 'Running') status_color = 'text-success';
|
||||
else if (container.status === 'Stopped') status_color = 'text-warning';
|
||||
else if (container.status === 'Error') status_color = 'text-danger';
|
||||
|
||||
// Форматируем даты
|
||||
let created_at = container.created_at !== 'Unknown' ?
|
||||
new Date(container.created_at).toLocaleString() : 'Unknown';
|
||||
let last_used_at = container.last_used_at !== 'Unknown' ?
|
||||
new Date(container.last_used_at).toLocaleString() : 'Unknown';
|
||||
|
||||
table_html += `
|
||||
<tr>
|
||||
<td><strong>${container.name}</strong></td>
|
||||
<td><span class="${status_color}">${container.status}</span></td>
|
||||
<td>${container.type}</td>
|
||||
<td>${container.architecture}</td>
|
||||
<td>${created_at}</td>
|
||||
<td>${last_used_at}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
table_html += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p><strong>Всего контейнеров: ${response.total_count}</strong></p>
|
||||
`;
|
||||
|
||||
// Показываем диалог с таблицей
|
||||
frappe.msgprint({
|
||||
title: "Список контейнеров Incus",
|
||||
message: table_html,
|
||||
wide: true
|
||||
});
|
||||
|
||||
// Также выводим в консоль для разработчика
|
||||
console.log("Containers list:", response.containers);
|
||||
|
||||
} else {
|
||||
frappe.msgprint({
|
||||
title: "Ошибка",
|
||||
indicator: "red",
|
||||
message: response.message || "Не удалось получить список контейнеров"
|
||||
});
|
||||
|
||||
console.error("Error:", response.error);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: function(r) {
|
||||
frappe.msgprint({
|
||||
title: "Системная ошибка",
|
||||
indicator: "red",
|
||||
message: "Произошла ошибка при получении списка контейнеров"
|
||||
});
|
||||
console.error("API call error:", r);
|
||||
}
|
||||
});
|
||||
}, "Действия");
|
||||
}
|
||||
});
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
{
|
||||
"actions": [],
|
||||
"allow_rename": 1,
|
||||
"creation": "2025-09-03 19:23:42.889432",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"container_name",
|
||||
"image",
|
||||
"container_type",
|
||||
"server",
|
||||
"status",
|
||||
"ip_address",
|
||||
"section_break_details",
|
||||
"created_at",
|
||||
"last_used_at",
|
||||
"architecture",
|
||||
"section_break_pmeb"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"fieldname": "container_name",
|
||||
"fieldtype": "Data",
|
||||
"label": "Container Name",
|
||||
"reqd": 1,
|
||||
"description": "Уникальное имя контейнера в Incus"
|
||||
},
|
||||
{
|
||||
"fieldname": "image",
|
||||
"fieldtype": "Data",
|
||||
"label": "Image Alias",
|
||||
"reqd": 1,
|
||||
"default": "alpine/edge",
|
||||
"description": "Алиас образа Incus (например: alpine/edge, ubuntu/22.04)"
|
||||
},
|
||||
{
|
||||
"fieldname": "container_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Container Type",
|
||||
"options": "container\nvirtual-machine",
|
||||
"default": "container",
|
||||
"description": "Тип экземпляра: контейнер или виртуальная машина"
|
||||
},
|
||||
{
|
||||
"fieldname": "server",
|
||||
"fieldtype": "Data",
|
||||
"label": "Server",
|
||||
"default": "router1",
|
||||
"description": "Адрес Incus API (пример: router1 или 10.0.0.1)"
|
||||
},
|
||||
{
|
||||
"fieldname": "status",
|
||||
"fieldtype": "Select",
|
||||
"label": "Status",
|
||||
"options": "Creating\nProcessing\nCreated\nRunning\nStopped\nError",
|
||||
"default": "Creating",
|
||||
"read_only": 1,
|
||||
"description": "Текущий статус контейнера"
|
||||
},
|
||||
{
|
||||
"fieldname": "ip_address",
|
||||
"fieldtype": "Data",
|
||||
"label": "IP Address",
|
||||
"read_only": 1,
|
||||
"description": "IP адрес контейнера (если назначен)"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Детали контейнера"
|
||||
},
|
||||
{
|
||||
"fieldname": "created_at",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Created At",
|
||||
"read_only": 1,
|
||||
"description": "Дата и время создания контейнера в Incus"
|
||||
},
|
||||
{
|
||||
"fieldname": "last_used_at",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Last Used At",
|
||||
"read_only": 1,
|
||||
"description": "Дата и время последнего использования"
|
||||
},
|
||||
{
|
||||
"fieldname": "architecture",
|
||||
"fieldtype": "Data",
|
||||
"label": "Architecture",
|
||||
"read_only": 1,
|
||||
"description": "Архитектура контейнера (x86_64, aarch64, etc.)"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_pmeb",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2025-09-03 19:23:42.889432",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Incus Manager",
|
||||
"name": "Incus Container",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
"create": 1,
|
||||
"delete": 1,
|
||||
"email": 1,
|
||||
"export": 1,
|
||||
"print": 1,
|
||||
"read": 1,
|
||||
"report": 1,
|
||||
"role": "System Manager",
|
||||
"share": 1,
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
import frappe
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from frappe.model.document import Document
|
||||
|
||||
# === Настройки Incus API ===
|
||||
INCUS_HOST = "https://10.220.187.117:8443"
|
||||
CLIENT_CERT = ("/home/frappe/frappe-bench/apps/jey_erp/client.crt", "/home/frappe/frappe-bench/apps/jey_erp/client.key")
|
||||
VERIFY = False
|
||||
|
||||
class IncusContainer(Document):
|
||||
"""DocType для управления контейнерами Incus"""
|
||||
pass
|
||||
|
||||
def wait_for_operation(operation_url, timeout=60):
|
||||
"""
|
||||
Ждёт завершения асинхронной операции в Incus
|
||||
"""
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
try:
|
||||
r = requests.get(
|
||||
operation_url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
cert=CLIENT_CERT,
|
||||
verify=VERIFY
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
op_data = r.json()
|
||||
metadata = op_data.get("metadata", {})
|
||||
status = metadata.get("status")
|
||||
|
||||
if status == "Success":
|
||||
return {
|
||||
"success": True,
|
||||
"operation_data": op_data,
|
||||
"message": "Операция успешно завершена"
|
||||
}
|
||||
elif status == "Failure":
|
||||
return {
|
||||
"success": False,
|
||||
"operation_data": op_data,
|
||||
"error": metadata.get("err", "Unknown error"),
|
||||
"message": "Операция завершена с ошибкой"
|
||||
}
|
||||
elif status in ["Running", "Pending"]:
|
||||
time.sleep(2)
|
||||
continue
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Ошибка при проверке статуса операции"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Timeout",
|
||||
"message": f"Операция не завершилась за {timeout} секунд"
|
||||
}
|
||||
|
||||
@frappe.whitelist()
|
||||
def list_containers():
|
||||
"""
|
||||
Получает список контейнеров
|
||||
"""
|
||||
url = f"{INCUS_HOST}/1.0/instances"
|
||||
|
||||
try:
|
||||
r = requests.get(
|
||||
url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
cert=CLIENT_CERT,
|
||||
verify=VERIFY
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
containers_data = r.json()
|
||||
|
||||
# Получаем детальную информацию о каждом контейнере
|
||||
detailed_containers = []
|
||||
|
||||
if containers_data.get("metadata"):
|
||||
for container_url in containers_data["metadata"]:
|
||||
# container_url выглядит как "/1.0/instances/container_name"
|
||||
container_name = container_url.split("/")[-1]
|
||||
|
||||
# Получаем подробную информацию о контейнере
|
||||
detail_url = f"{INCUS_HOST}{container_url}"
|
||||
try:
|
||||
detail_r = requests.get(
|
||||
detail_url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
cert=CLIENT_CERT,
|
||||
verify=VERIFY
|
||||
)
|
||||
detail_r.raise_for_status()
|
||||
container_detail = detail_r.json()
|
||||
|
||||
if container_detail.get("metadata"):
|
||||
metadata = container_detail["metadata"]
|
||||
detailed_containers.append({
|
||||
"name": container_name,
|
||||
"status": metadata.get("status", "Unknown"),
|
||||
"type": metadata.get("type", "Unknown"),
|
||||
"architecture": metadata.get("architecture", "Unknown"),
|
||||
"created_at": metadata.get("created_at", "Unknown"),
|
||||
"last_used_at": metadata.get("last_used_at", "Unknown")
|
||||
})
|
||||
except:
|
||||
# Если не удалось получить детали, добавляем базовую информацию
|
||||
detailed_containers.append({
|
||||
"name": container_name,
|
||||
"status": "Unknown",
|
||||
"type": "Unknown",
|
||||
"architecture": "Unknown",
|
||||
"created_at": "Unknown",
|
||||
"last_used_at": "Unknown"
|
||||
})
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"containers": detailed_containers,
|
||||
"total_count": len(detailed_containers),
|
||||
"message": f"Найдено контейнеров: {len(detailed_containers)}"
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Ошибка получения списка контейнеров"
|
||||
}
|
||||
|
||||
@frappe.whitelist()
|
||||
def create_container(name, image_alias="alpine/edge", container_type="container", wait_completion=True):
|
||||
"""
|
||||
Создаёт новый контейнер
|
||||
|
||||
Args:
|
||||
name (str): Имя контейнера
|
||||
image_alias (str): Алиас образа (по умолчанию alpine/edge)
|
||||
container_type (str): Тип контейнера (по умолчанию container)
|
||||
wait_completion (bool): Ждать ли завершения операции (по умолчанию True)
|
||||
|
||||
Returns:
|
||||
dict: Результат операции создания контейнера
|
||||
"""
|
||||
url = f"{INCUS_HOST}/1.0/instances"
|
||||
|
||||
# Подготавливаем данные для создания контейнера
|
||||
container_data = {
|
||||
"name": name,
|
||||
"type": container_type,
|
||||
"source": {
|
||||
"type": "image",
|
||||
"alias": image_alias
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
# Отправляем запрос на создание контейнера
|
||||
r = requests.post(
|
||||
url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
cert=CLIENT_CERT,
|
||||
verify=VERIFY,
|
||||
data=json.dumps(container_data)
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
response_data = r.json()
|
||||
|
||||
# Если wait_completion=False, возвращаем сразу
|
||||
if not wait_completion:
|
||||
return {
|
||||
"success": True,
|
||||
"container_name": name,
|
||||
"operation_url": response_data.get("operation"),
|
||||
"message": f"Запрос на создание контейнера '{name}' отправлен"
|
||||
}
|
||||
|
||||
# Ждём завершения операции
|
||||
operation_url = response_data.get("operation")
|
||||
if operation_url:
|
||||
full_operation_url = f"{INCUS_HOST}{operation_url}"
|
||||
operation_result = wait_for_operation(full_operation_url, timeout=120)
|
||||
|
||||
if operation_result["success"]:
|
||||
return {
|
||||
"success": True,
|
||||
"container_name": name,
|
||||
"image_alias": image_alias,
|
||||
"container_type": container_type,
|
||||
"operation_data": operation_result["operation_data"],
|
||||
"message": f"Контейнер '{name}' успешно создан"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"container_name": name,
|
||||
"error": operation_result.get("error"),
|
||||
"operation_data": operation_result.get("operation_data"),
|
||||
"message": f"Ошибка при создании контейнера '{name}': {operation_result.get('error')}"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"container_name": name,
|
||||
"error": "No operation URL returned",
|
||||
"message": f"Не удалось получить URL операции для контейнера '{name}'"
|
||||
}
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# Обрабатываем HTTP ошибки более детально
|
||||
error_detail = ""
|
||||
try:
|
||||
error_response = e.response.json()
|
||||
error_detail = error_response.get("error", str(e))
|
||||
except:
|
||||
error_detail = str(e)
|
||||
|
||||
return {
|
||||
"success": False,
|
||||
"container_name": name,
|
||||
"error": error_detail,
|
||||
"status_code": e.response.status_code if e.response else None,
|
||||
"message": f"HTTP ошибка при создании контейнера '{name}': {error_detail}"
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
"success": False,
|
||||
"container_name": name,
|
||||
"error": str(e),
|
||||
"message": f"Ошибка сети при создании контейнера '{name}': {str(e)}"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"container_name": name,
|
||||
"error": str(e),
|
||||
"message": f"Неожиданная ошибка при создании контейнера '{name}': {str(e)}"
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Copyright (c) 2025, Jey ERP and Contributors
|
||||
# See license.txt
|
||||
|
||||
# import frappe
|
||||
from frappe.tests import IntegrationTestCase, UnitTestCase
|
||||
|
||||
|
||||
# On IntegrationTestCase, the doctype test records and all
|
||||
# link-field test record depdendencies are recursively loaded
|
||||
# Use these module variables to add/remove to/from that list
|
||||
EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
|
||||
|
||||
|
||||
class UnitTestIncusContainer(UnitTestCase):
|
||||
"""
|
||||
Unit tests for IncusContainer.
|
||||
Use this class for testing individual functions and methods.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class IntegrationTestIncusContainer(IntegrationTestCase):
|
||||
"""
|
||||
Integration tests for IncusContainer.
|
||||
Use this class for testing interactions between multiple components.
|
||||
"""
|
||||
|
||||
pass
|
||||
Loading…
Reference in New Issue