""" Main webapp handlers. """ # Pokhara, Nepal (DEC/18). 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, 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 _vo_notes_image_cache_dname = None _asl_rulebook2_targets = None _asl_rulebook2_target_url_template = None # --------------------------------------------------------------------- @app.route( "/vehicles/notes" ) def get_vehicle_notes(): """Return the Chapter H vehicle notes.""" if not globvars.vo_notes: abort( 404 ) return jsonify( globvars.vo_notes[ "vehicles" ] ) @app.route( "/ordnance/notes" ) def get_ordnance_notes(): """Return the Chapter H ordnance notes.""" if not globvars.vo_notes: abort( 404 ) return jsonify( globvars.vo_notes[ "ordnance" ] ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - def load_vo_notes( msg_store ): #pylint: disable=too-many-statements,too-many-locals,too-many-branches """Load the Chapter H vehicle/ordnance notes.""" # locate the data directory dname = app.config.get( "CHAPTER_H_NOTES_DIR" ) if dname: # NOTE: If the Chapter H directory has been configured but is incorrect, we want to keep going, # since this may well happen when running in a container (the directory has to be always configured, # but the user may not have mapped it to a directory outside the container). dname = os.path.abspath( dname ) if not os.path.isdir( dname ): error_msg = "Missing Chapter H directory: {}".format( dname ) logging.error( "%s", error_msg ) if msg_store: msg_store.error( error_msg ) dname = None if not dname: globvars.vo_notes = { "vehicles": {}, "ordnance": {} } globvars.vo_notes_file_server = None return file_server = FileServer( dname ) # generate a list of extension ID's extn_ids = set() if globvars.vasl_mod: extns = globvars.vasl_mod.get_extns() extn_ids = set( e[1]["extensionId"] for e in extns ) extn_ids.update( [ "kfw-un", "kfw-comm" ] ) def get_ma_note_key( nat, fname ): """Get the key for a multi-applicable note.""" # NOTE: Windows has a case-insensitive file system, so we adopt the following convention: # - filenames are assumed to be upper-case e.g. "a.html" holds Multi-Applicable Note "A" # - unless it has a trailing underscore, in which it is interpreted as lower-case # e.g. "a_.html" holds Multi-Applicable Note "a". fname = os.path.splitext( fname )[0] if fname.endswith( "_" ): return fname[:-1].lower() else: fname = fname.upper() # NOTE: Allied/Axis Minor multi-applicable notes have keys like "Gr" and "Da", # but we need to be careful we don't transform keys like "AA" and "BB". if nat in ("allied-minor","axis-minor") and len(fname) == 2 and fname[0] != fname[1]: fname = fname[0] + fname[1].lower() return fname # initialize vo_notes = { "vehicles": defaultdict(dict), "ordnance": defaultdict(dict) } # NOTE: We don't have any data files for these vehicles/ordnance, but they have # multi-applicable notes, so we force them to appear in the final results. vo_notes["vehicles"]["british~anzac"] = {} vo_notes["ordnance"]["indonesian"] = {} vo_note_layout_width = app.config.get( "VO_NOTE_LAYOUT_WIDTH", 500 ) # load the vehicle/ordnance notes for root,_,fnames in os.walk( dname, followlinks=True ): # initialize dname2, vo_type2 = os.path.split( root ) if vo_type2 in extn_ids: extn_id = vo_type2 dname2, vo_type2 = os.path.split( dname2 ) else: extn_id = None if app.config.get( "_DISABLE_LANDING_CRAFT_" ) and vo_type2 == "landing-craft": # NOTE: Landing craft are usually disabled during tests (so that we can detect # when the code has forgotten to consider them). continue if vo_type2 not in ("vehicles","ordnance","landing-craft"): continue if os.path.split( dname2 )[1] == "tests": continue nat = os.path.split( dname2 )[1] if vo_type2 == "landing-craft": vo_type2, nat2 = "vehicles", "landing-craft" else: nat2 = nat # process each file in the next directory ma_notes = {} for fname in fnames: # ignore placeholder files fname = os.path.join( root, fname ) if is_empty_file( fname ): continue # figure out what kind of file we have extn = os.path.splitext( fname )[1].lower() if is_image_file( extn ): # image file - check if this looks like a vehicle/ordnance note key = os.path.splitext( os.path.split( fname )[1] )[0] if not all( ch.isdigit() or ch == "." for ch in key ): # nope (this could be e.g. an image that's part of an HTML vehicle/ordnance note) continue # yup - save it as a vehicle/ordnance note if extn_id: key = "{}:{}".format( extn_id, key ) # NOTE: We only do this if we don't already have an HTML version. if not vo_notes.get( vo_type2, {} ).get( nat2, {} ).get( key ): rel_path = os.path.relpath( fname, dname ) vo_notes[vo_type2][nat2][key] = { "filename": rel_path.replace( "\\", "/" ) } elif extn == ".html": # HTML file - read the content fname = os.path.join( root, fname ) html_content = read_text_file( fname ).strip() if "½" in html_content: # NOTE: VASSAL doesn't like this, use "frac12;" :-/ logging.warning( "Found ½ in HTML: %s", fname ) # check what kind of file we have key = get_ma_note_key( nat2, os.path.split(fname)[1] ) if re.search( r"^\d+(\.\d+)?$", key ): # FUDGE! The HTML version of the Chapter H content contain a lot of notes # that start with "

†". We detect these and add a CSS class. # Larger blocks of content need to be wrapped in a

. html_content = re.sub( r"^<(p|div)>\s*†", r"<\1 class='dagger-note'> †", html_content, flags=re.MULTILINE ) # check if the content is specifying its own layout if "" not in html_content: # nope - use the default one html_content = "
\n{}\n
".format( vo_note_layout_width, html_content ) # save it as a vehicle/ordnance note if extn_id: key = "{}:{}".format( extn_id, key ) rel_path = os.path.relpath( os.path.split(fname)[0], dname ) vo_notes[ vo_type2 ][ nat2 ][ key ] = { "filename": fname, "content": _fixup_urls( html_content, "{{CHAPTER_H}}/" + rel_path.replace( "\\", "/" ) + "/" ) } else: # save it as a multi-applicable note if extn_id: key = "{}:{}".format( extn_id, key ) if html_content.startswith( "

" ): html_content = html_content[3:].strip() rel_path = os.path.relpath( os.path.split(fname)[0], dname ) ma_notes[ key ] = _fixup_urls( html_content, "{{CHAPTER_H}}/" + rel_path.replace( "\\", "/" ) + "/" ) if "multi-applicable" in vo_notes[ vo_type2 ][ nat2 ]: vo_notes[ vo_type2 ][ nat2 ][ "multi-applicable" ].update( ma_notes ) else: vo_notes[ vo_type2 ][ nat2 ][ "multi-applicable" ] = ma_notes # update nationality variants with the notes from their base nationality for vo_type2, vo_notes2 in vo_notes.items(): # FUDGE! Some nationalities don't have any vehicles/ordnance of their own, so we have to do this manually. # NOTE: We do a deep copy so that these new nationalities don't get affected by changes we make # to the base nationality later (e.g. adding K:FW counters to the British). if "chinese" in vo_notes2: vo_notes2["chinese~gmd"] = copy.deepcopy( vo_notes2["chinese"] ) if "british" in vo_notes2: vo_notes2["british~canadian"] = copy.deepcopy( vo_notes2["british"] ) vo_notes2["british~newzealand"] = copy.deepcopy( vo_notes2["british"] ) vo_notes2["british~australian"] = copy.deepcopy( vo_notes2["british"] ) vo_notes2["british~anzac"] = copy.deepcopy( vo_notes2["british"] ) def install_kfw_vo_notes( nat, vo_type, extn_id, include ): """Install the K:FW vehicle/ordnance notes into the specified nationality.""" target_vo_notes = vo_notes[vo_type].get( nat ) if not target_vo_notes: return kfw_vo_notes = vo_notes[vo_type].get( extn_id ) if not kfw_vo_notes: return target_vo_notes.update( { "{}:{}".format( extn_id, key ): val for key,val in kfw_vo_notes.items() if not include or include( int(key) ) } ) def install_kfw_ma_notes( nat, vo_type, kfw_ma_notes, extn_id ): """Install the K:FW vehicle/ordnance multi-applicable notes into the specified nationality.""" if not kfw_ma_notes: return if nat not in vo_notes[vo_type]: vo_notes[vo_type][nat] = {} ma_notes = vo_notes[vo_type][nat].get( "multi-applicable" ) if not ma_notes: ma_notes = vo_notes[vo_type][nat]["multi-applicable"] = {} ma_notes.update( { "{}:{}".format( extn_id, key ): val for key,val in kfw_ma_notes.items() } ) # install the UN vehicle/ordnance notes and multi-applicable notes kfw_ma_notes = vo_notes["vehicles"].get( "kfw-un", {} ).pop( "multi-applicable", None ) for nat in ("american","kfw-rok","kfw-ounc"): install_kfw_ma_notes( nat, "vehicles", kfw_ma_notes, "kfw-un" ) install_kfw_vo_notes( nat, "vehicles", "kfw-un", lambda key: key <= 33 or key >= 54 ) install_kfw_ma_notes( "british", "vehicles", kfw_ma_notes, "kfw-un" ) install_kfw_vo_notes( "british", "vehicles", "kfw-un", lambda key: key >= 34 ) kfw_ma_notes = vo_notes["ordnance"].get( "kfw-un", {} ).pop( "multi-applicable", None ) for nat in ("american","kfw-rok","kfw-ounc"): install_kfw_ma_notes( nat, "ordnance", kfw_ma_notes, "kfw-un" ) install_kfw_vo_notes( nat, "ordnance", "kfw-un", lambda key: key <= 13 or key >= 21 ) install_kfw_ma_notes( "british", "ordnance", kfw_ma_notes, "kfw-un" ) install_kfw_vo_notes( "british", "ordnance", "kfw-un", lambda key: key >= 14 ) # install the Communist vehicle/ordnance notes and multi-applicable notes kfw_ma_notes = vo_notes["vehicles"].get( "kfw-comm", {} ).pop( "multi-applicable", None ) install_kfw_ma_notes( "kfw-kpa", "vehicles", kfw_ma_notes, "kfw-comm" ) install_kfw_vo_notes( "kfw-kpa", "vehicles", "kfw-comm", None ) kfw_ma_notes = vo_notes["ordnance"].get( "kfw-comm", {} ).pop( "multi-applicable", None ) for nat in ("kfw-kpa","kfw-cpva"): install_kfw_ma_notes( nat, "ordnance", kfw_ma_notes, "kfw-comm" ) install_kfw_vo_notes( "kfw-kpa", "ordnance", "kfw-comm", lambda key: key <= 15 ) install_kfw_vo_notes( "kfw-cpva", "ordnance", "kfw-comm", lambda key: key >= 16 ) # install the vehicle/ordnance notes globvars.vo_notes = { k: dict(v) for k,v in vo_notes.items() } globvars.vo_notes_file_server = file_server def _fixup_urls( html, url_stem ): """Fixup URL's to Chapter H files.""" matches = list( re.finditer( r"]*src=(['\"])(.*?)\1", html ) ) for mo in reversed(matches): before, after = html[:mo.start(2)], html[mo.start(2):] if after.startswith( ( "http://", "https://", "file://" ) ): continue html = before + url_stem + after return html # --------------------------------------------------------------------- @app.route( "///note/" ) def get_vo_note( vo_type, nat, key ): """Return a Chapter H vehicle/ordnance note.""" # get the vehicle/ordnance note vo_notes = globvars.vo_notes[ vo_type ] vo_note = vo_notes.get( nat, {} ).get( key ) if not vo_note: abort( 404 ) if not globvars.vo_notes_file_server: abort( 404 ) # serve the file if "content" not in vo_note: resp = globvars.vo_notes_file_server.serve_file( vo_note["filename"], ignore_empty=True ) if not resp: abort( 404 ) default_scaling = app.config.get( "CHAPTER_H_IMAGE_SCALING", 100 ) return resize_image_response( resp, default_scaling=default_scaling ) else: buf = _make_vo_note_html( vo_note["content"] ) if request.args.get( "f" ) == "html": # return the content as HTML return Response( buf, mimetype="text/html" ) else: # return the content as an image # NOTE: We offer this option since VASSAL's HTML engine is so ancient, it doesn't support # floating images (which we really need), either via CSS "float", or the HTML "align" attribute. # NOTE: We need our own WebDriver instance in case the user is trying to generate a snippet image, # which will use the shared instance (thus locking it), but vehicle/ordnance notes can contain # a link that calls us here to generate the Chapter H content as an image, and if this 2nd request # gets handled in a different thread (which it certainly will, since the 1st request is still # in progress), we will deadlock waiting for the shared instance to become available. cached_fname = _make_vo_note_cached_image_fname( vo_type, nat, key ) if cached_fname and os.path.isfile( cached_fname ): # we have a cached copy - compare the timestamps of the source HTML and the cached image # NOTE: We should also check the HTML for any associated images, and check their timestamps, as well. if os.path.getmtime( cached_fname ) >= os.path.getmtime( vo_note["filename"] ): # FUDGE! We get errors on Windows when using waitress to serve the webapp, when the tests end # and ControlTestsServicer tries to clean up its TemporaryDirectory ("not a directory" errors # for something that is a file :-/). TemporaryDirectory added a ignore_cleanup_errors argument # in Python 3.10, but for now, we work-around this problem by reading the file ourself and # serving it from memory. with open( cached_fname, "rb" ) as fp: buf = fp.read() resp = send_file( io.BytesIO( buf ), download_name = os.path.basename( cached_fname ) ) resp.headers[ "X-WasCached" ] = 1 return resp with WebDriver.get_instance( "vo_note" ) as webdriver: img = webdriver.get_snippet_screenshot( None, buf ) buf = io.BytesIO() img.save( buf, format="PNG" ) buf.seek( 0 ) if cached_fname: # save a copy of the generated image os.makedirs( os.path.dirname( cached_fname ), exist_ok=True ) with open( cached_fname, "wb" ) as fp: fp.write( buf.read() ) buf.seek( 0 ) return send_file( buf, mimetype="image/png" ) def _make_vo_note_html( content ): """Generate the HTML for a vehicle/ordnance note.""" # initialize url_root = request.url_root if url_root.endswith( "/" ): url_root = url_root[:-1] # inject the CSS (we do it like this since VASSAL doesn't support :-/) css = [ globvars.template_pack.get( "css", {} ).get( "common", "" ), globvars.template_pack.get( "css", {} ).get( "ob_vo_note", "" ), ] if any( css ): content = "\n\n\n\n{}".format( "\n".join(css), content ) # update any parameters content = content.replace( "{{CHAPTER_H}}", url_root+"/chapter-h" ) content = content.replace( "{{IMAGES_BASE_URL}}", url_root+url_for("static",filename="images") ) return content def _make_vo_note_cached_image_fname( vo_type, nat, key ): """Get the name of the cached vehicle/ordnance note image.""" if not _vo_notes_image_cache_dname: return None # NOTE: Extensions will have keys like "adf-bj:12", so we put these in their own sub-directory. # NOTE: ":" in file paths was also causing problems on Windows :-/ key = key.replace( ":", os.sep ) return os.path.join( _vo_notes_image_cache_dname, vo_type, nat, key+".png" ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @app.route( "/load-vo-notes-image-cache" ) def load_vo_notes_image_cache(): """Show the helper page to preload the v/o notes image cache.""" if not _vo_notes_image_cache_dname: return render_template( "configure-vo-notes-image-cache.html", NO_CACHE_DIR = True ) return render_template( "load-vo-notes-image-cache.html" ) # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - @app.route( "/chapter-h/" ) def get_chapter_h_file( path ): """Return a Chapter H file.""" if not globvars.vo_notes_file_server: abort( 404 ) return globvars.vo_notes_file_server.serve_file( path, ignore_empty=True ) # --------------------------------------------------------------------- @app.route( "///notes" ) def get_vo_notes_report( nat, vo_type ): """Get a Chapter H vehicles/ordnance notes report.""" # generate the report return render_template( "vo-notes-report.html", 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: # FUDGE! We should really 404 here, but this causes a warning to appear in the browser console :-/ return jsonify( {} ) return jsonify( _asl_rulebook2_targets ) @app.route( "/asl-rulebook2/" ) 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 ): with open( base_url, "r", encoding="utf-8" ) as fp: _asl_rulebook2_targets = json.load( fp ) else: with urllib.request.urlopen( base_url + "/vo-note-targets" ) as resp: _asl_rulebook2_targets = json.load( resp ) 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}" )