Added asl-rulebook2 integration.

master
Pacman Ghost 3 years ago
parent 1d3af963df
commit f3ae34878c
  1. 3
      vasl_templates/main_window.py
  2. 8
      vasl_templates/webapp/__init__.py
  3. 1
      vasl_templates/webapp/static/css/sortable.css
  4. 2
      vasl_templates/webapp/static/css/tabs-ob.css
  5. BIN
      vasl_templates/webapp/static/images/aslrb2.png
  6. 13
      vasl_templates/webapp/static/main.js
  7. 4
      vasl_templates/webapp/static/utils.js
  8. 52
      vasl_templates/webapp/static/vo.js
  9. 2
      vasl_templates/webapp/templates/index.html
  10. 1
      vasl_templates/webapp/tests/control_tests_servicer.py
  11. 60
      vasl_templates/webapp/tests/fixtures/asl-rulebook2/vo-note-targets.json
  12. 4
      vasl_templates/webapp/tests/fixtures/data/default-template-pack/nationalities.json
  13. 1
      vasl_templates/webapp/tests/fixtures/data/ordnance/british.json
  14. 1
      vasl_templates/webapp/tests/fixtures/data/ordnance/kfw-cpva.json
  15. 8
      vasl_templates/webapp/tests/fixtures/data/ordnance/kfw/bcfk.json
  16. 8
      vasl_templates/webapp/tests/fixtures/data/ordnance/kfw/cpva.json
  17. 8
      vasl_templates/webapp/tests/fixtures/data/ordnance/kfw/un-common.json
  18. 1
      vasl_templates/webapp/tests/fixtures/data/vehicles/kfw-kpa.json
  19. 8
      vasl_templates/webapp/tests/fixtures/data/vehicles/kfw/kpa.json
  20. 8
      vasl_templates/webapp/tests/fixtures/data/vehicles/kfw/un-common.json
  21. 8
      vasl_templates/webapp/tests/fixtures/data/vehicles/kfw/us-rok-ounc.json
  22. 1
      vasl_templates/webapp/tests/fixtures/data/vehicles/landing-craft.json
  23. 117
      vasl_templates/webapp/tests/test_aslrb2.py
  24. 48
      vasl_templates/webapp/vo_notes.py

@ -28,7 +28,8 @@ class AppWebPage( QWebEnginePage ):
def acceptNavigationRequest( self, url, nav_type, is_mainframe ): #pylint: disable=no-self-use,unused-argument
"""Called when a link is clicked."""
if url.host() in ("localhost","127.0.0.1"):
return True
if "/asl-rulebook2/" not in url.url(): # nb: asl-rulebook2 links are routed through our webapp
return True
if not is_mainframe:
# NOTE: We get here if we're in a child frame (e.g. Google Maps). However, we can't just ignore
# these requests, because the help is also in a frame, and we want links to open in an external browser.

@ -52,6 +52,9 @@ def _init_webapp():
# NOTE: While this is generally called only once (before the first request), the test suite
# can force it be done again, since it wants to reconfigure the server to test different cases.
# initialize
from vasl_templates.webapp.main import startup_msg_store #pylint: disable=cyclic-import
# start downloading files
# NOTE: We used to do this in the mainline code of __init__, so that we didn't have to wait
# for the first request before starting the download (useful if we are running as a standalone server).
@ -71,7 +74,6 @@ def _init_webapp():
# configure the VASL module
fname = app.config.get( "VASL_MOD" )
from vasl_templates.webapp.vasl_mod import set_vasl_mod #pylint: disable=cyclic-import
from vasl_templates.webapp.main import startup_msg_store #pylint: disable=cyclic-import
set_vasl_mod( fname, startup_msg_store )
# load the vehicle/ordnance listings
@ -82,6 +84,10 @@ def _init_webapp():
from vasl_templates.webapp.vo_notes import load_vo_notes #pylint: disable=cyclic-import
load_vo_notes( startup_msg_store )
# load integration data from asl-rulebook2
from vasl_templates.webapp.vo_notes import load_asl_rulebook2_vo_note_targets #pylint: disable=cyclic-import
load_asl_rulebook2_vo_note_targets( startup_msg_store )
# ---------------------------------------------------------------------
def _load_config( fname, section ):

@ -9,6 +9,7 @@ img.sortable-reset { vertical-align: middle ; height: 15px ; margin-right: 0.25e
.sortable li:hover { cursor: pointer ; }
.sortable li.ui-sortable-helper { opacity: 0.8 ; }
.sortable li img.snippet { height: 1.25em ; margin: -2px -2px ; padding-left: 1em ; float: right ; }
.sortable li img.aslrb2 { height: 1.25em ; position: absolute ; bottom: -2px ; right: -2px ; opacity: 0.6 ; }
.sortable ul li, .sortable ol li { margin-top: -0.75em ; } /* nb: tighten up lists in sortable2 entries */

@ -21,7 +21,7 @@
.panel-ob_ordnance .footer { margin-top: 0.5em ; display: flex ; align-items: center ; }
/* nb: the following CSS is shared by vehicles and ordnance */
.panel-ob_vo .sortable .vo-entry { display: flex ; }
.panel-ob_vo .sortable .vo-entry { display: flex ; position: relative ; }
.panel-ob_vo .sortable .vo-entry img.vasl-image { display: inline-block ; vertical-align: middle ; height: 3.25em ; margin-right: 0.5em ; }
.panel-ob_vo .sortable .vo-entry.small-piece img.vasl-image { height: 2.25em ; margin-left: 0.5em ; margin-right: 0.75em ; }
.panel-ob_vo .sortable .vo-entry .detail { flex-grow: 1 ; display: flex ; flex-direction: column ; justify-content: center ; }

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

@ -6,6 +6,7 @@ gVehicleOrdnanceListings = {} ;
gVehicleOrdnanceNotes = {} ;
gVaslPieceInfo = {} ;
gOnlineCounterImages = {} ;
gAslRulebook2VoNoteTargets = {} ;
gWebChannelHandler = null ;
gEmSize = null ;
@ -347,6 +348,16 @@ $(document).ready( function () {
update_page_load_status( "template-pack" ) ;
} ) ;
// get the ASL Rulebook2 vehicle/ordnance note targets
$.getJSON( gGetAslRulebook2VoNoteTargetsUrl, function(data) {
gAslRulebook2VoNoteTargets = data ;
update_page_load_status( "asl-rulebook2-vo-note-targets" ) ;
} ).fail( function( xhr, status, errorMsg ) {
if ( xhr.status != 404 )
showErrorMsg( "Can't get the ASL Rulebook2 vehicle/ordnance note targets:<div class='pre'>" + escapeHTML(errorMsg) + "</div>" ) ;
update_page_load_status( "asl-rulebook2-vo-note-targets" ) ;
} ) ;
// fixup the layout
var prevHeight = [] ;
$(window).resize( function() {
@ -534,7 +545,7 @@ function init_snippet_button( $btn )
gPageLoadStatus = [
"main", "app-config",
"vehicle-listings", "ordnance-listings", "reset-scenario",
"vehicle-notes", "ordnance-notes",
"vehicle-notes", "ordnance-notes", "asl-rulebook2-vo-note-targets",
"vasl-piece-info", "online-counter-images", "template-pack", "default-scenario"
] ;

@ -459,7 +459,7 @@ function get_month_name( month )
// --------------------------------------------------------------------
function fixup_external_links( $root )
function fixup_external_links( $root, fixAll )
{
// NOTE: We want to open externals links in a new browser window, but simply adding target="_blank"
// breaks the desktop app's ability to intercept clicks (in AppWebPage.acceptNavigationRequest()),
@ -467,7 +467,7 @@ function fixup_external_links( $root )
var regex = new RegExp( "^https?://" ) ;
$root.find( "a" ).each( function() {
var url = $(this).attr( "href" ) ;
if ( url && url.match( regex ) )
if ( fixAll || ( url && url.match( regex ) ) )
$(this).attr( "target", gWebChannelHandler?"":"_blank" ) ;
} ) ;
}

@ -155,6 +155,9 @@ function do_add_vo( vo_type, player_no, vo_entry, vo_image_id, elite, custom_cap
{
// initialize
var nat = get_player_nat( player_no ) ;
var nat_type = gTemplatePack.nationalities[ nat ].type ;
var vo_note_key = get_vo_note_key( vo_entry ) ;
var is_landing_craft = vo_note_key ? vo_note_key.substring( 0, 3 ) === "LC " : null ;
var $sortable2 = $( "#ob_" + vo_type + "-sortable_" + player_no ) ;
if ( seq_id === null ) {
// auto-assign a sequence ID
@ -165,6 +168,43 @@ function do_add_vo( vo_type, player_no, vo_entry, vo_image_id, elite, custom_cap
seq_id = auto_assign_id( usedSeqIds, "seq_id" ) ;
}
// check if an asl-rulebook2 Chapter H note is available
var aslrb2_url = null ;
var aslrb2_nat = nat ;
if ( [ "allied-minor", "axis-minor" ].indexOf( nat_type ) != -1 )
aslrb2_nat = nat_type ;
else {
var pos = aslrb2_nat.indexOf( "~" ) ;
if ( pos > 0 ) {
// NOTE: This is a derived nationality - use the base nationality.
aslrb2_nat = aslrb2_nat.substring( 0, pos ) ;
} else {
// check for K:FW vehicles/ordnance
pos = vo_entry.id.indexOf( "/" ) ;
if ( pos > 0 ) {
var nat2 = vo_entry.id.substring( 0, pos ) ;
if ( nat2 == "kfw-uro" || nat2 == "kfw-bcfk" || nat2 == "kfw-un-common")
aslrb2_nat = "un-forces" ;
else if ( nat2 == "kfw-kpa" || nat2 == "kfw-cpva" )
aslrb2_nat = "communist-forces" ;
}
}
}
var entries = is_landing_craft ? gAslRulebook2VoNoteTargets["landing-craft"] : gAslRulebook2VoNoteTargets[aslrb2_nat] && gAslRulebook2VoNoteTargets[aslrb2_nat][vo_type] ;
if ( entries ) {
var key = vo_note_key ;
if ( is_landing_craft )
key = vo_note_key.substring( 3 ) ;
else {
var match = key.match( /^kfw-(un|un-common|comm):/ ) ;
if ( match )
key = key.substring( match[0].length ) ;
}
var aslrb2_entry = entries[ key ] ;
if ( aslrb2_entry )
aslrb2_url = gShowAslRulebook2VoNoteUrl.replace( "TARGET", aslrb2_entry.target ) ;
}
// add the specified vehicle/ordnance
// NOTE: We set a fixed height for the sortable2 entries (based on the CSS settings in tabs-ob.css),
// so that the vehicle/ordnance images won't get truncated if there are a lot of them.
@ -194,11 +234,10 @@ function do_add_vo( vo_type, player_no, vo_entry, vo_image_id, elite, custom_cap
"<div class='vo-capabilities'></div>",
"</div>"
] ;
var vo_note_key = get_vo_note_key( vo_entry ) ;
var vo_note = get_vo_note( vo_type, nat, vo_note_key ) ;
var vo_note_image_url = null ;
if ( vo_note ) {
if ( vo_note_key.substring( 0, 3 ) === "LC " )
if ( is_landing_craft )
vo_note_image_url = make_app_url( "/" + vo_type + "/landing-craft/note/" + vo_note_key.substring(3), true ) ;
else
vo_note_image_url = make_app_url( "/" + vo_type + "/" + nat + "/note/" + vo_note_key, true ) ;
@ -206,7 +245,6 @@ function do_add_vo( vo_type, player_no, vo_entry, vo_image_id, elite, custom_cap
// NOTE: Note numbers seem to be distinct across all Allied Minor or all Axis Minor vehicles/ordnance,
// so if we don't find a note in a given nationality's normal vehicles/ordnance, we can get away with
// just checking their corresponding common vehicles/ordnance.
var nat_type = gTemplatePack.nationalities[ nat ].type ;
if ( ["allied-minor","axis-minor"].indexOf( nat_type ) !== -1 ) {
vo_note = get_vo_note( vo_type, nat_type, vo_note_key ) ;
if ( vo_note )
@ -224,8 +262,16 @@ function do_add_vo( vo_type, player_no, vo_entry, vo_image_id, elite, custom_cap
data.vo_note = vo_note ;
data.vo_note_image_url = vo_note_image_url ;
}
if ( aslrb2_url ) {
buf.push(
"<a href='" + aslrb2_url + "' class='aslrb2'>",
"<img src='" + gImagesBaseUrl + "/aslrb2.png' class='aslrb2' title='Chapter H'>",
"</a>"
) ;
}
buf.push( "</div>" ) ;
var $content = $( buf.join("") ) ;
fixup_external_links( $content, true ) ;
var $entry = $sortable2.sortable2( "add", {
content: $content,
data: data,

@ -126,6 +126,8 @@ gImagesBaseUrl = "{{url_for('static',filename='images')}}" ;
gAppConfigUrl = "{{url_for('get_app_config')}}" ;
gGetStartupMsgsUrl = "{{url_for('get_startup_msgs')}}" ;
gGetTemplatePackUrl = "{{url_for('get_template_pack')}}" ;
gGetAslRulebook2VoNoteTargetsUrl = "{{url_for('get_asl_rulebook2_vo_note_targets')}}" ;
gShowAslRulebook2VoNoteUrl = "{{url_for('show_asl_rulebook2_target',target='TARGET')}}" ;
gGetDefaultScenarioUrl = "{{url_for('get_default_scenario')}}" ;
gVehicleListingsUrl = "{{url_for('get_vehicle_listings',merge_common=1)}}" ;
gOrdnanceListingsUrl = "{{url_for('get_ordnance_listings',merge_common=1)}}" ;

@ -156,6 +156,7 @@ class ControlTestsServicer( BaseControlTestsServicer ): #pylint: disable=too-man
self.setAppConfigVal( SetAppConfigValRequest( key="DISABLE_DOWNLOADED_FILES", boolVal=True ), ctx )
self.setAppConfigVal( SetAppConfigValRequest( key="DISABLE_LOCAL_ASA_INDEX_UPDATES", boolVal=True ), ctx )
self.setAppConfigVal( SetAppConfigValRequest( key="DISABLE_LFA_HOTNESS_FADEIN", boolVal=True ), ctx )
self.deleteAppConfigVal( DeleteAppConfigValRequest( key="ASL_RULEBOOK2_BASE_URL" ), ctx )
self.deleteAppConfigVal( DeleteAppConfigValRequest( key="ALTERNATE_WEBAPP_BASE_URL" ), ctx )
# NOTE: The webapp has been reconfigured, but the client must reloaed the home page
# with "?force-reinit=1", to force it to re-initialize with the new settings.

@ -0,0 +1,60 @@
{
"german": {
"vehicles": {
"1": { "caption": "german/vehicles #1", "target": "gv:1" }
}
},
"russian": {
"ordnance": {
"2": { "caption": "russian/ordnance #2", "target": "ro:2" }
}
},
"allied-minor": {
"vehicles": {
"1": { "caption": "dutch/vehicles #1", "target": "dv:1" },
"101": { "caption": "allied-minor/vehicles #101", "target": "almv:101" }
}
},
"axis-minor": {
"ordnance": {
"4": { "caption": "romanian/ordnance #4", "target": "ro:4" },
"104": { "caption": "axis-minor/ordnance #104", "target": "axmo:104" }
}
},
"landing-craft": {
"1": { "caption": "landing-craft #1", "target": "lc:1" },
"2": { "caption": "landing-craft #2", "target": "lc:2" }
},
"chinese": {
"vehicles": {
"1": { "caption": "chinese/vehicles #1", "target": "chv:1" }
}
},
"un-forces": {
"vehicles": {
"5": { "caption": "un-forces/vehicles #5", "target": "kfw-un:5" },
"6": { "caption": "un-forces/vehicles #6", "target": "kfw-un:6" }
},
"ordnance": {
"7": { "caption": "un-forces/ordnance #7", "target": "kfw-un:7" },
"8": { "caption": "un-forces/ordnance #8", "target": "kfw-un:8" }
}
},
"communist-forces": {
"vehicles": {
"15": { "caption": "communist-forces/vehicles #15", "target": "kfw-comm:15" }
},
"ordnance": {
"16": { "caption": "communist-forces/ordnance #16", "target": "kfw-comm:16" }
}
}
}

@ -81,6 +81,10 @@
"type": "axis-minor"
},
"kfw-kpa": {
"display_name": "North Korean",
"ob_colors": [ "OBCOL:kfw-kpa","OBCOL2:kfw-kpa", "OBCOL-BORDER:kfw-kpa" ]
},
"kfw-cpva": {
"display_name": "Communist Chinese",
"ob_colors": [ "OBCOL:kfw-cpva","OBCOL2:kfw-cpva", "OBCOL-BORDER:kfw-cpva" ]

@ -0,0 +1,8 @@
[
{ "name": "kfw british ordnance",
"note_number": "7",
"id": "kfw-bcfk/o:7"
}
]

@ -0,0 +1,8 @@
[
{ "name": "cpva ordnance",
"note_number": "16",
"id": "kfw-cpva/o:016"
}
]

@ -0,0 +1,8 @@
[
{ "name": "kfw common ordnance",
"note_number": "8\u2020",
"id": "kfw-un-common/o:008"
}
]

@ -0,0 +1,8 @@
[
{ "name": "kpa vehicle",
"note_number": "15",
"id": "kfw-kpa/v:015"
}
]

@ -0,0 +1,8 @@
[
{ "name": "kfw common vehicle",
"note_number": "6\u2020",
"id": "kfw-un-common/v:006"
}
]

@ -0,0 +1,8 @@
[
{ "name": "kfw us vehicle",
"note_number": "5",
"id": "kfw-uro/v:005"
}
]

@ -1,6 +1,7 @@
[
{ "name": "landing craft",
"note_number": "1",
"notes": [ "A" ],
"id": "sh/v:000",
"gpid": [ 399, 397 ]

@ -0,0 +1,117 @@
""" Test integration with asl-rulebook2. """
import os
from vasl_templates.webapp.tests.utils import init_webapp, find_child, find_children
from vasl_templates.webapp.tests.test_scenario_persistence import load_scenario
# ---------------------------------------------------------------------
def test_chapter_h( webapp, webdriver ):
"""Test links to Chapter H vehicle/ordnance notes."""
# initialize
webapp.control_tests.set_app_config_val( "ASL_RULEBOOK2_BASE_URL",
os.path.join( os.path.dirname(__file__), "fixtures/asl-rulebook2/vo-note-targets.json" )
)
init_webapp( webapp, webdriver, scenario_persistence=1 )
base_url = "{}/asl-rulebook2/".format( webapp.base_url )
# test normal vehicles/ordnance
load_scenario( {
"PLAYER_1": "german",
"OB_VEHICLES_1": [ { "name": "a german vehicle" }, { "name": "another german vehicle" } ],
"PLAYER_2": "russian",
"OB_ORDNANCE_2": [ { "name": "a russian ordnance" }, { "name": "another russian ordnance" } ],
} )
urls = _unload_aslrb2_urls( base_url )
assert urls == [
[ [ "gv:1", None ], [] ],
[ [], [ None, "ro:2" ] ]
]
# test Allied/Axis Minor vehicles/ordnance
load_scenario( {
"PLAYER_1": "dutch",
"OB_VEHICLES_1": [ { "name": "dutch vehicle" }, { "name": "common allied minor vehicle" } ],
"PLAYER_2": "romanian",
"OB_ORDNANCE_2": [ { "name": "romanian ordnance" }, { "name": "common axis minor ordnance" } ],
} )
urls = _unload_aslrb2_urls( base_url )
assert urls == [
[ [ "dv:1", "almv:101" ], [] ],
[ [], [ "ro:4", "axmo:104", ] ]
]
# test Landing Craft
load_scenario( {
"PLAYER_1": "american",
"OB_VEHICLES_1": [ { "name": "landing craft" } ],
"PLAYER_2": "japanese",
"OB_VEHICLES_2": [ { "name": "Daihatsu" } ],
} )
urls = _unload_aslrb2_urls( base_url )
assert urls == [
[ [ "lc:1" ], [] ],
[ [ "lc:2" ], [] ]
]
# test derived nationalities
load_scenario( {
"PLAYER_1": "chinese~gmd",
"OB_VEHICLES_1": [ { "name": "a chinese vehicle" } ],
} )
urls = _unload_aslrb2_urls( base_url )
assert urls == [
[ ["chv:1"], [] ],
[ [], [] ]
]
# test K:FW (UN Forces)
load_scenario( {
"PLAYER_1": "american",
"OB_VEHICLES_1": [ { "name": "kfw us vehicle" }, { "name": "kfw common vehicle" } ],
"PLAYER_2": "british",
"OB_ORDNANCE_2": [ { "name": "kfw british ordnance" }, { "name": "kfw common ordnance" } ],
} )
urls = _unload_aslrb2_urls( base_url )
assert urls == [
[ ["kfw-un:5","kfw-un:6"], [] ],
[ [], ["kfw-un:7","kfw-un:8"] ]
]
# test K:FW (Communist Forces)
load_scenario( {
"PLAYER_1": "kfw-kpa",
"OB_VEHICLES_1": [ { "name": "kpa vehicle" } ],
"PLAYER_2": "kfw-cpva",
"OB_ORDNANCE_2": [ { "name": "cpva ordnance" } ],
} )
urls = _unload_aslrb2_urls( base_url )
assert urls == [
[ ["kfw-comm:15"], [] ],
[ [], ["kfw-comm:16"] ]
]
# ---------------------------------------------------------------------
def _unload_aslrb2_urls( base_url ):
"""Unload the URL's to the asl-rulebook2 vehicle/ordnance notes."""
urls = [
[ [], [] ],
[ [], [] ]
]
for player_no in (1,2):
for vo_type_index, vo_type in enumerate(["vehicles","ordnance"]):
sortable = find_child( "#ob_{}-sortable_{}".format( vo_type, player_no ) )
urls2 = urls[ player_no-1 ][ vo_type_index ]
for vo_entry in find_children( ".vo-entry", sortable ):
link = find_child( "a.aslrb2", vo_entry )
if link:
url = link.get_attribute( "href" )
if url.startswith( base_url ):
url = url[ len(base_url): ]
else:
url = None
urls2.append( url )
return urls

@ -4,17 +4,22 @@
import os
import io
import re
import json
import copy
import logging
import urllib.request
from collections import defaultdict
from flask import request, render_template, jsonify, send_file, abort, Response, url_for
from flask import request, render_template, jsonify, send_file, abort, redirect, Response, url_for
from vasl_templates.webapp import app, globvars
from vasl_templates.webapp.files import FileServer
from vasl_templates.webapp.webdriver import WebDriver
from vasl_templates.webapp.utils import read_text_file, resize_image_response, is_image_file, is_empty_file
_asl_rulebook2_targets = None
_asl_rulebook2_target_url_template = None
# ---------------------------------------------------------------------
@app.route( "/vehicles/notes" )
@ -351,3 +356,44 @@ def get_vo_notes_report( nat, vo_type ):
NATIONALITY = nat,
VO_TYPE = vo_type
)
# ---------------------------------------------------------------------
@app.route( "/asl-rulebook2/vo-note-targets" )
def get_asl_rulebook2_vo_note_targets():
"""Return the Chapter H vehicle/ordnance note targets."""
if not _asl_rulebook2_targets:
abort( 404 )
return jsonify( _asl_rulebook2_targets )
@app.route( "/asl-rulebook2/<path:target>" )
def show_asl_rulebook2_target( target ):
"""Show the specified asl-rulebook2 target."""
base_url = app.config.get( "ASL_RULEBOOK2_BASE_URL" )
if not base_url:
abort( 404 )
url = "{}?target={}".format( base_url, target )
return redirect( url, code=307 )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def load_asl_rulebook2_vo_note_targets( msg_store ):
"""Load the Chapter H vehicle/ordnance note targets."""
global _asl_rulebook2_targets, _asl_rulebook2_target_url_template
_asl_rulebook2_targets = _asl_rulebook2_target_url_template = None
base_url = app.config.get( "ASL_RULEBOOK2_BASE_URL" )
if not base_url:
return
try:
if os.path.isfile( base_url ):
fp = open( base_url, "r", encoding="utf-8" )
else:
fp = urllib.request.urlopen( base_url + "/vo-note-targets" )
_asl_rulebook2_targets = json.load( fp )
except Exception as ex: #pylint: disable=broad-except
msg = str( getattr(ex,"reason",None) or ex )
msg_store.warning( "Couldn't get the ASL Rulebook2 Chapter H targets: {}".format( msg ) )
return
_asl_rulebook2_target_url_template = app.config.get( "ASL_RULEBOOK2_TARGET_URL_TEMPLATE",
base_url + "/chapter-h/{NAT}/{VO-TYPE}/{ID}"
)

Loading…
Cancel
Save