Commit 194a9181 authored by Yankovskiy Georgiy's avatar Yankovskiy Georgiy

Merge resolve

parents a01be939 a2989022
...@@ -131,8 +131,4 @@ pyvenv.cfg ...@@ -131,8 +131,4 @@ pyvenv.cfg
venv venv
pip-selfcheck.json pip-selfcheck.json
# End of https://www.toptal.com/developers/gitignore/api/pycharm,venv # End of https://www.toptal.com/developers/gitignore/api/pycharm,venv
\ No newline at end of file
### Extra
.agent-data
\ No newline at end of file
...@@ -6,31 +6,26 @@ from time import sleep ...@@ -6,31 +6,26 @@ from time import sleep
from pathlib import Path from pathlib import Path
from typing import AnyStr, Union from typing import AnyStr, Union
import requests
from PySide6 import QtCore from PySide6 import QtCore
from os.path import expanduser from os.path import expanduser
from desktop_parser import DesktopFile from desktop_parser import DesktopFile
from platformdirs import user_cache_dir, user_config_dir
from ingame.models.Gamepad import Gamepad from ingame.models.Gamepad import Gamepad
from ingame.models.GamesModel import Game, GamesModel from ingame.models.GamesModel import GamesModel
from ingame.models.GameEntry import GameEntry
from ingame.models.GameAgent import GameAgent from ingame.models.GameAgent import GameAgent
from PySide6.QtCore import Property, Signal, Slot, QObject, Qt from PySide6.QtCore import Property, Signal, Slot, QObject, Qt
from steamgrid import SteamGridDB class App(QtCore.QObject):
app_name = "ingame"
class GameShortcut: app_author = "foss"
def __init__(self, filename, product_name, icon):
self.filename = filename
self.product_name = product_name
self.icon = icon
game_list_details_retrieving_progress = Signal(float, name="gameListDetailsRetrievingProgress")
class App(QtCore.QObject):
game_started = Signal(bool, name="gameStarted") game_started = Signal(bool, name="gameStarted")
game_ended = Signal(bool, name="gameEnded") game_ended = Signal(bool, name="gameEnded")
data_found = Signal(dict, name="gotGameData") data_found = Signal(dict, name="gotGameData")
gamepad_clicked_LB = Signal(bool, name="gamepadClickedLB") gamepad_clicked_LB = Signal(bool, name="gamepadClickedLB")
gamepad_clicked_RB = Signal(bool, name="gamepadClickedRB") gamepad_clicked_RB = Signal(bool, name="gamepadClickedRB")
gamepad_clicked_apply = Signal(bool, name="gamepadClickedApply") gamepad_clicked_apply = Signal(bool, name="gamepadClickedApply")
...@@ -40,9 +35,12 @@ class App(QtCore.QObject): ...@@ -40,9 +35,12 @@ class App(QtCore.QObject):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.games_model: GamesModel = GamesModel()
self.home: AnyStr = expanduser('~') self.home: AnyStr = expanduser('~')
self.config_location: str = '/.config/PortProton.conf' self.config_path = user_config_dir(App.app_name, App.app_author)
self.cache_path = user_cache_dir(App.app_name, App.app_author)
self.games_model: GamesModel = GamesModel()
self.portproton_config_location: str = '/.config/PortProton.conf'
self.portproton_location: str = '' self.portproton_location: str = ''
self.running_game_process: Union[subprocess.Popen, None] = None self.running_game_process: Union[subprocess.Popen, None] = None
...@@ -54,94 +52,94 @@ class App(QtCore.QObject): ...@@ -54,94 +52,94 @@ class App(QtCore.QObject):
self.gamepad.r_clicked = lambda: self.gamepad_axis_right.emit(True) self.gamepad.r_clicked = lambda: self.gamepad_axis_right.emit(True)
self.gamepad.back_clicked = lambda: self.gamepad_clicked_back.emit(True) self.gamepad.back_clicked = lambda: self.gamepad_clicked_back.emit(True)
self.agent = GameAgent() self.agent = GameAgent(self.config_path, self.cache_path)
self.setup() self.setup()
def setup(self): def setup(self):
try: try:
with open(self.home + self.config_location, 'r') as file: with open(self.home + self.portproton_config_location, 'r') as file:
self.portproton_location = file.read().strip() self.portproton_location = file.read().strip()
print(f'Current PortProton location: {self.portproton_location}') print(f'Current PortProton location: {self.portproton_location}')
self.games_model.clear() self.games_model.clear()
files = glob.glob(f"{self.portproton_location}/*.desktop") files = glob.glob(f"{self.portproton_location}/*.desktop")
for val in files: for val in files:
desktop_file = DesktopFile.from_file(val) desktop_file = DesktopFile.from_file(val)
data = desktop_file.data desktop_file_data = desktop_file.data
entry = data['Desktop Entry'] desktop_entry = desktop_file_data['Desktop Entry']
_name = entry['Name'] or 'generic' entry_name = desktop_entry['Name'] or 'generic'
_exec = 'Exec' in entry and entry['Exec'] or '' entry_exec = 'Exec' in desktop_entry and desktop_entry['Exec'] or ''
_icon = entry['Icon'] entry_icon = desktop_entry['Icon']
assert (isinstance(_name, str) assert (isinstance(entry_name, str)
and isinstance(_exec, str) and isinstance(entry_exec, str)
and isinstance(_icon, str)) and isinstance(entry_icon, str))
exec_split = _exec.split(' ') entry_exec_split = entry_exec.split(' ')
# Ignore extra non-related desktop entries # Ignore extra non-related desktop entries
if (len(exec_split) <= 1 or if (len(entry_exec_split) <= 1 or
('data/scripts/start.sh' not in exec_split[1] or '%F' in exec_split[-1])): ('data/scripts/start.sh' not in entry_exec_split[1] or '%F' in entry_exec_split[-1])):
continue continue
# TODO parse product name # TODO parse product name
url_img = find_image(_name)
_icon = (url_img entry_icon = (os.path.isfile(entry_icon) and entry_icon) or ''
or os.path.realpath(f"{Path(__file__).resolve().parent}../../../qml/images/PUBG.png"))
# Автозапуск игры:
# PW_GUI_DISABLED_CS=1
# START_FROM_STEAM=1
# Remove extra env in the beginning # Remove extra env in the beginning
_exec = _exec[4:len(_exec)] entry_exec = f"env START_FROM_STEAM=1 {entry_exec[4:len(entry_exec)]}"
_exec = f"env START_FROM_STEAM=1 {_exec}"
self.games_model.add_game(Game(name=_name, icon=_icon, exec=_exec)) self.games_model.add_game(GameEntry(name=entry_name, icon=entry_icon, exec=entry_exec))
self.gamepad.run() self.gamepad.run()
self.retrieve_games_details()
except FileNotFoundError: except FileNotFoundError:
print('File not found') print('File not found')
except Exception as e: except Exception as e:
print('An error occurred', e) print('An error occurred', e)
pass pass
### CALLBACKS ### # TODO: fix: progress=1.0 not emitted if details already cached/downloaded
def retrieve_games_details(self):
def retrieve_games_details_thread(t):
t.game_list_details_retrieving_progress.emit(0.0)
all_count: int = len(self.games_model.games_list)
game_entry: GameEntry
i: int = 0
for game_entry in self.games_model.games_list:
game_description = t.agent.retrieve_game_description(game_entry.name)
game_entry.icon = game_description['image_location_path'] or game_entry.icon
t.game_list_details_retrieving_progress.emit(float(i) / all_count)
i += 1
t.game_list_details_retrieving_progress.emit(1.0)
thread = threading.Thread(target=retrieve_games_details_thread, args=(self,))
thread.start()
''' CALLBACKS '''
def close_event(self): def close_event(self):
# do stuff
# if can_exit:
self.gamepad.terminate() self.gamepad.terminate()
# event.accept() # let the window close
# else:
# event.ignore()
self.agent.clean_data() self.agent.clean_data()
# self.agent.save_db()
### SLOTS ### ''' SLOTS '''
@Slot(str, result=dict) @Slot(str, result=dict)
def get_game_data(self, game_name): def get_game_data(self, game_name):
def get_game_data_thread(t, name):
#print(game_name) search_result = t.agent.retrieve_game_description(name)
def search_thread(t, name):
search_result = t.agent.search_game(name)
t.data_found.emit(search_result) t.data_found.emit(search_result)
return return
thread = threading.Thread(target=search_thread, args=(self, game_name)) thread = threading.Thread(target=get_game_data_thread, args=(self, game_name))
thread.start() thread.start()
pass
@Slot(str) @Slot(str)
def start_game(self, exec): def start_game(self, _exec):
self.game_started.emit(True) self.game_started.emit(True)
def run_in_thread(t, _exec): def run_in_thread(t, _exec):
...@@ -154,40 +152,13 @@ class App(QtCore.QObject): ...@@ -154,40 +152,13 @@ class App(QtCore.QObject):
) )
t.running_game_process.wait() t.running_game_process.wait()
t.game_ended.emit(True) t.game_ended.emit(True)
# output = self.running_game_process.stdout.read()
# self.running_game_process.stdout.close()
return return
thread = threading.Thread(target=run_in_thread, args=(self, exec)) thread = threading.Thread(target=run_in_thread, args=(self, _exec))
thread.start() thread.start()
pass ''' PROPERTIES '''
### PROPERTIES ###
@Property(QObject, constant=True) @Property(QObject, constant=True)
def games(self): def games(self):
return self.games_model return self.games_model
def find_image(game_name):
steamgriddb = SteamGridDB('66827eabea66de47d036777ed2be87b2')
save_path = f"{Path(__file__).resolve().parent}/../../qml/images/{game_name}.png"
if os.path.exists(save_path):
print("FOUND!")
return save_path
result = steamgriddb.search_game(game_name)
grids = steamgriddb.get_grids_by_gameid(list([result[0].id]))
for grid in grids:
if grid.height == 900 and grid.width == 600:
url_img = grid.url
response = requests.get(url_img)
print(save_path)
with open(save_path, 'wb') as file:
file.write(response.content)
break
return url_img
\ No newline at end of file
import os import os
import pickle import pickle
import requests
from steam_web_api import Steam from steam_web_api import Steam
from steamgrid import SteamGridDB
# TODO: from ingame.models.GameDescription import GameDescription
# [?] Определиться, используется ли Lutris. Если да, вместо этого будет обращение к нему. Если нет, import time
# продумать "рыбу" более логично.
# [?] Починить отображение системных требований (точнее, разобраться, что именно возвращает API.
# [done 1/2] Додумать форматированные данные, что именно мы видим на странице игры?
class GameAgent: class GameAgent:
generic_name = "Risk of rain 2" # generic_name = "Risk of rain 2"
datapath = ".agent-data" # scenario = 0
all_data = dict() # db_storage_path = ".agent-data"
scenario = 0 # data = dict()
def __init__(self): def __init__(self, config_path, cache_path):
super().__init__() super().__init__()
agent_key = "SOME_KEY_HERE_I_GUESS" # TODO: move API tokens to GUI settings tab / environmental variables
self.steam_process = Steam(agent_key) self.steam_grid_db_client = SteamGridDB("66827eabea66de47d036777ed2be87b2")
self.get_all_data() self.steam_client = Steam("SOME_KEY_HERE_I_GUESS")
self.config_path = config_path
self.cache_path = cache_path
self.db_storage_path = config_path + "/.agent-data"
self.steam_grid_db_images_path = cache_path + "/steam_grid_db_images"
self.data = dict()
os.makedirs(self.config_path, exist_ok=True, mode=0o755)
os.makedirs(self.steam_grid_db_images_path, exist_ok=True, mode=0o755)
self.load_db()
''' USAGE '''
def retrieve_game_description(self, game_name):
if game_name not in self.data:
# TODO: checkup for failed requests
search_results = self.steam_client.apps.search_games(game_name)
self.__add_game_description(search_results, game_name)
game_description = self.data[game_name]
return game_description.as_dict()
''' DATABASE '''
def __steam_grid_db_retrieve_image(self, game_name):
try:
save_path = f"{self.steam_grid_db_images_path}/{game_name}.png"
if os.path.exists(save_path):
return save_path
# TODO: checkup for failed requests
result = self.steam_grid_db_client.search_game(game_name)
grids = self.steam_grid_db_client.get_grids_by_gameid(list([result[0].id]))
# TODO: too slow, replace loop o(n) with o(1) if possible
for grid in grids:
if grid.height == 900 and grid.width == 600:
url_img = grid.url
response = requests.get(url_img)
with open(save_path, 'wb') as file:
file.write(response.content)
# return url_img
return save_path
return ''
except:
return ''
def __add_game_description(self, search_results, game_name):
game_description = GameDescription()
game_description.locked = True
self.data[game_name] = game_description
def add_game_info(self, data, name): # Steam game info
if not data['apps']: if search_results['apps']:
self.all_data[name] = 0 game_id = search_results['apps'][0]['id'][0]
else: # TODO: checkup for failed requests
game_id = data['apps'][0]['id'] app_details = self.steam_client.apps.get_app_details(game_id)
data = self.steam_process.apps.get_app_details(game_id) app_data = app_details[str(game_id)]['data']
self.all_data[name] = data[str(game_id[0])]['data']
with open(self.datapath, "wb+") as datafile:
pickle.dump(self.all_data, datafile)
self.get_all_data()
def search_game(self, game_name): game_description.title = app_data['name']
game_description.desc = app_data['short_description']
game_description.reqs = ((app_data['linux_requirements']
and (
app_data['linux_requirements']['minimum'] or
app_data['linux_requirements']['recommended']
))
or (app_data['pc_requirements']
and (
app_data['pc_requirements']['minimum'] or
app_data['pc_requirements']['recommended']
))
or '-')
game_description.languages = app_data['supported_languages']
self.get_all_data() # Steam Grid DB image retrieving
game_description.image_location_path = self.__steam_grid_db_retrieve_image(game_name)
game_description.locked = False
self.save_db()
if game_name in self.all_data: def save_db(self):
print("ITS HERE!") with open(self.db_storage_path, "wb+") as datafile:
else: pickle.dump(self.data, datafile)
search_results = self.steam_process.apps.search_games(game_name)
self.add_game_info(search_results, game_name)
return self.format_game_data(self.all_data[game_name])
def get_all_data(self): def load_db(self):
try: try:
with open(self.datapath, "rb") as datafile: with open(self.db_storage_path, "rb") as datafile:
self.all_data = pickle.load(datafile) self.data = pickle.load(datafile)
except FileNotFoundError: except FileNotFoundError:
self.all_data = dict() self.data = dict()
def format_game_data(self, game_data):
formatted_data = dict()
if game_data != 0:
formatted_data['title'] = game_data['name']
formatted_data['desc'] = game_data['short_description']
formatted_data['languages'] = game_data['supported_languages']
formatted_data['reqs'] = game_data['linux_requirements']
# for key, value in formatted_data.items():
# print("{0}: {1}".format(key, value))
else:
#TODO исправить это недоразумение, временная затычка
formatted_data['title'] = "Информация не найдена!"
formatted_data['desc'] = "Информация не найдена!"
formatted_data['languages'] = "Информация не найдена!"
formatted_data['reqs'] = "Информация не найдена!"
# print(formatted_data)
return formatted_data
def clean_data(self): def clean_data(self):
self.all_data = dict() self.data = dict()
with open(self.datapath, "wb") as datafile:
pickle.dump(self.all_data, datafile)
print("data cleaned")
from dataclasses import dataclass
@dataclass
class GameDescription:
locked: bool = False
title: str = 'Информация не найдена!'
desc: str = 'Информация не найдена!'
languages: str = 'Информация не найдена!'
reqs: str = 'Информация не найдена!'
image_location_path: str = ''
def as_dict(self):
formatted_data = dict()
formatted_data['title'] = self.title
formatted_data['desc'] = self.desc
formatted_data['languages'] = self.languages
formatted_data['reqs'] = self.reqs
formatted_data['image_location_path'] = self.image_location_path
return formatted_data
from dataclasses import dataclass
@dataclass
class GameEntry:
name: str = ''
exec: str = ''
icon: str = ''
import typing import typing
from dataclasses import dataclass, fields from dataclasses import fields
from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt, QByteArray from PySide6.QtCore import QAbstractListModel, QModelIndex, Qt, QByteArray
from ingame.models.GameEntry import GameEntry
@dataclass
class Game:
name: str = ''
exec: str = ''
icon: str = ''
class GamesModel(QAbstractListModel): class GamesModel(QAbstractListModel):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._list = [] self.games_list = []
def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> typing.Any: def data(self, index: QModelIndex, role: int = Qt.DisplayRole) -> typing.Any:
if 0 <= index.row() < self.rowCount(): if 0 <= index.row() < self.rowCount():
student = self._list[index.row()] student = self.games_list[index.row()]
name = self.roleNames().get(role) name = self.roleNames().get(role)
if name: if name:
return getattr(student, name.decode()) return getattr(student, name.decode())
def roleNames(self) -> dict[int, QByteArray]: def roleNames(self) -> dict[int, QByteArray]:
d = {} d = {}
for i, field in enumerate(fields(Game)): for i, field in enumerate(fields(GameEntry)):
d[Qt.DisplayRole + i] = field.name.encode() d[Qt.DisplayRole + i] = field.name.encode()
return d return d
def rowCount(self, index: QModelIndex = QModelIndex()) -> int: def rowCount(self, index: QModelIndex = QModelIndex()) -> int:
return len(self._list) return len(self.games_list)
def add_game(self, game: Game) -> None: def add_game(self, game: GameEntry) -> None:
self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
self._list.append(game) self.games_list.append(game)
self.endInsertRows() self.endInsertRows()
def clear(self) -> None: def clear(self) -> None:
self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount()) self.beginInsertRows(QModelIndex(), self.rowCount(), self.rowCount())
self._list = [] self.games_list = []
self.endInsertRows() self.endInsertRows()
pass pass
...@@ -256,6 +256,22 @@ files = [ ...@@ -256,6 +256,22 @@ files = [
] ]
[[package]] [[package]]
name = "platformdirs"
version = "4.2.2"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
optional = false
python-versions = ">=3.8"
files = [
{file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"},
{file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"},
]
[package.extras]
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
type = ["mypy (>=1.8)"]
[[package]]
name = "pygame" name = "pygame"
version = "2.5.2" version = "2.5.2"
description = "Python Game Development" description = "Python Game Development"
...@@ -511,4 +527,4 @@ zstd = ["zstandard (>=0.18.0)"] ...@@ -511,4 +527,4 @@ zstd = ["zstandard (>=0.18.0)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = ">=3.11,<3.13" python-versions = ">=3.11,<3.13"
content-hash = "25bf5edd372b37ccd07dd1f5dd40b16fe884c5ab918957b9a2f5c282b0a7dfd4" content-hash = "8acdd92388f970774431a6b4f12e8e5c0d5d01c0d88d748ae7a4a049b50a5005"
...@@ -17,6 +17,7 @@ desktop-parser = "^0.1.1" ...@@ -17,6 +17,7 @@ desktop-parser = "^0.1.1"
pygame = "^2.5.2" pygame = "^2.5.2"
python-steam-api = "^2.0" python-steam-api = "^2.0"
python-steamgriddb = "^1.0.5" python-steamgriddb = "^1.0.5"
platformdirs = "^4.2.2"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
mypy = "^1.9.0" mypy = "^1.9.0"
......
...@@ -312,6 +312,7 @@ Rectangle { ...@@ -312,6 +312,7 @@ Rectangle {
// Повторитель // Повторитель
Repeater { Repeater {
id: gamesGridRepeater
model: core_app.games model: core_app.games
// Карточка игры // Карточка игры
Game { Game {
...@@ -365,6 +366,7 @@ Rectangle { ...@@ -365,6 +366,7 @@ Rectangle {
// LOGIC // LOGIC
property int focusedItems: 0; property int focusedItems: 0;
property int focusedTabs: 0; property int focusedTabs: 0;
...@@ -412,28 +414,33 @@ Rectangle { ...@@ -412,28 +414,33 @@ Rectangle {
// c[tabs.focusedItems].clicked(); // c[tabs.focusedItems].clicked();
} }
function onGamepadClickedLB(done){ /* SIGNALS */
if(window.scene !== S.homeScene) return;
function onGamepadClickedLB(args){
tabs.applyTabsFocus(-1) tabs.applyTabsFocus(-1)
} }
function onGamepadClickedRB(done){ function onGamepadClickedRB(args){
if(window.scene !== S.homeScene) return;
tabs.applyTabsFocus(1) tabs.applyTabsFocus(1)
} }
function onGamepadAxisLeft(done){ function onGamepadAxisLeft(args){
if(window.scene !== S.homeScene) return;
tabs.applyItemsFocus(-1) tabs.applyItemsFocus(-1)
} }
function onGamepadAxisRight(done){ function onGamepadAxisRight(args){
if(window.scene !== S.homeScene) return;
tabs.applyItemsFocus(1) tabs.applyItemsFocus(1)
} }
function onGamepadClickedApply(done){ function onGamepadClickedApply(args){
if(window.scene !== S.homeScene) return; if(window.scene !== S.homeScene) return;
// console.log("onGamepadClickedApply");
let c = gamesGrid.children; let c = gamesGrid.children;
c[tabs.focusedItems].press(); c[tabs.focusedItems].press();
} }
function onGameListDetailsRetrievingProgress(args) {
let progress = args[0];
console.log(progress);
if(progress === 1.0){
gamesGridRepeater.model = [];
gamesGridRepeater.model = core_app.games;
}
}
} }
......
...@@ -16,7 +16,7 @@ Window { ...@@ -16,7 +16,7 @@ Window {
} }
FontLoader { FontLoader {
id: globalFont; id: globalFont;
source: "./fonts/OpenSans-VariableFont_wdth.ttf" source: "./fonts/OpenSans-VariableFont.ttf"
} }
...@@ -52,6 +52,9 @@ Window { ...@@ -52,6 +52,9 @@ Window {
function onGamepadClickedBack(done){ function onGamepadClickedBack(done){
window._trigger("onGamepadClickedBack", done); window._trigger("onGamepadClickedBack", done);
} }
function onGameListDetailsRetrievingProgress(progress){
homeScene.onGameListDetailsRetrievingProgress([progress]);
}
} }
function _trigger(_method, ...args){ function _trigger(_method, ...args){
......
...@@ -41,20 +41,23 @@ Rectangle { ...@@ -41,20 +41,23 @@ Rectangle {
anchors.rightMargin: 0 anchors.rightMargin: 0
} }
function onGamepadClickedLB(done){ function onGamepadClickedLB(args){
tabs.onGamepadClickedLB(done) tabs.onGamepadClickedLB(args)
} }
function onGamepadClickedRB(done){ function onGamepadClickedRB(args){
tabs.onGamepadClickedRB(done) tabs.onGamepadClickedRB(args)
} }
function onGamepadAxisLeft(done){ function onGamepadAxisLeft(args){
tabs.onGamepadAxisLeft(done) tabs.onGamepadAxisLeft(args)
} }
function onGamepadAxisRight(done){ function onGamepadAxisRight(args){
tabs.onGamepadAxisRight(done) tabs.onGamepadAxisRight(args)
} }
function onGamepadClickedApply(done){ function onGamepadClickedApply(args){
tabs.onGamepadClickedApply(done) tabs.onGamepadClickedApply(args)
}
function onGameListDetailsRetrievingProgress(args){
tabs.onGameListDetailsRetrievingProgress(args)
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment