diff --git a/bin/qcms b/bin/qcms index d6272be..bd11e81 100755 --- a/bin/qcms +++ b/bin/qcms @@ -5,30 +5,33 @@ import sys import json import importlib import importlib.util -from git.repo.base import Repo +# from git.repo.base import Repo import typer from typing_extensions import Annotated from typing import Optional +from typing import Tuple + + import meta import uuid from termcolor import colored import base64 import io +import cms from cms import index -start = index.start +from cms.engine.config.structure import Layout, System +from cms.engine import project, config, themes, plugins +import pandas as pd +import requests -__doc__ = """ -(c) 2022 Quick Content Management System - QCMS -Health Information Privacy Lab - Vanderbilt University Medical Center -MIT License -Is a Python/Flask template for creating websites using disk structure to build the site and loading custom code -The basic template for a flask application will be created in a specified location, within this location will be found a folder "content" - - within the folder specify the folder structure that constitute the menu -Usage : - qcms --create --version --title --subtitle <subtitle> -""" +start = index.start +__doc__ = f""" +Built and designed by Steve L. Nyemba, steve@the-phi.com +version {meta.__version__} + +{meta.__license__}""" PASSED = ' '.join(['[',colored(u'\u2713', 'green'),']']) FAILED= ' '.join(['[',colored(u'\u2717','red'),']']) @@ -40,94 +43,6 @@ INVALID_FOLDER = """ # handling cli interface cli = typer.Typer() -def _get_config (path) : - if os.path.exists(path) : - f = open(path) - _conf = json.loads(f.read()) - f.close() - else: - _conf = {} - return _conf -def _isvalid(_allowed,**_args): - - if not list(set(_allowed) - set(_args.keys())) : - _pargs = {} - for key in _allowed : - _pargs [key] = _args[key] - return _pargs - return False - -def write_config(_config, path): - f = open(path,'w') - f.write( json.dumps(_config)) ; - f.close() - -def _system(**_args): - """ - Both version and context must be provided otherwise they are ignored - :version - :context context - """ - _info = _isvalid(['version','context'],**_args) - _info['theme'] = 'default.css' - _info = _info if _info else {'version':'0.0.0','context':'','theme':'default.css'} - if _info : - _info['logo'] = None if 'logo' not in _args else _args['logo'] - # - # There is an aggregation entry here in app - _appInfo = {'debug':True,'port':8084,'threaded':True,'host':'0.0.0.0'} - if 'app' not in _args: - _info['app'] = _appInfo - else: - _info['app'] = dict(_appInfo,**_args['app']) - - return _info -def _header(**_args): - return _isvalid(['logo','title','subtitle'],**_args) -def _layout(**_args): - _info = _isvalid(['root','index'],**_args) - _info['on'] = {"load":{},"error":{}} - _url = 'qcms.co' - _overwrite = {"folder1":{"type":"redirect","url":_url},"folder2":{"type":"dialog"},"folder3":{"type":"dialog","url":_url}} - _info['icons'] = {"comment":"use folder names as keys and fontawesome type as values to add icons to menu"} - _info["api"] = {"comment":"use keys as uri and function calls as values"} - _info['map'] = {}, - _info['order'] = {'menu':[]} - _info['overwrite'] = _overwrite - return _info - -def make (**_args): - """ - :context - :port port to be served - - :title title of the application - :root folder of the content to be scanned - """ - if 'port' in _args : - _args['app'] = {'port':_args['port']} - del _args['port'] - if 'context' not in _args : - _args['context'] = '' - _info ={'system': _system(**_args)} - _hargs = {'title':'QCMS'} if 'title' not in _args else {'title':_args['title']} - _hargs['subtitle'] = '' if 'subtitle' not in _args else _args['subtitle'] - _hargs['logo'] = True - - - - - _info['layout'] = _layout(root=_args['root'],index='index.html') - _info['layout']['header'] = _header(**_hargs) - # - - - if 'footer' in _args : - _info['layout']['footer'] = [{'text':_args['footer']}] if type(_args['footer']) != list else [{'text':term} for term in _args['footeer']] - else: - _info['layout']['footer'] = [{'text':'Vanderbilt University Medical Center'}] - - return _info @cli.command(name="info") def _info(): @@ -135,23 +50,23 @@ def _info(): This function returns metadata information about this program, no parameters are needed """ print () - print (meta.__name__,meta.__version__) + print ('QCMS',meta.__version__) print (meta.__author__,meta.__email__) print () -@cli.command(name='set-app') +@cli.command(name='setup') # def set_app (host:str="0.0.0.0",context:str="",port:int=8084,debug=True): def set_app (host:Annotated[str,typer.Argument(help="bind host IP address")]="0.0.0.0", context:Annotated[str,typer.Argument(help="if behind a proxy server (no forward slash needed)")]="", port:Annotated[int,typer.Argument(help="port on which to run the application")]=8084, debug:Annotated[bool,typer.Argument(help="set debug mode on|off")]=True): """ - This function consists in editing application access i.e port, debug, and/or context + Setup application access i.e port, debug, and/or context + These parameters are the same used in any flask application - :context alias or path for applications living behind proxy server """ global INVALID_FOLDER - _config = _get_config() + _config = config.get() if _config : _app = _config['system']['app'] _app['host'] = host @@ -159,21 +74,18 @@ def set_app (host:Annotated[str,typer.Argument(help="bind host IP address")]="0. _app['debug'] = debug _config['system']['context'] = context _config['app'] = _app - write_config(_config) + config.write(_config) _msg = f"""{PASSED} Successful update, good job ! """ else: _msg = INVALID_FOLDER print (_msg) -@cli.command(name='set-cloud') +@cli.command(name='cloud') # def set_cloud(path:str): #url:str,uid:str,token:str): def set_cloud(path:Annotated[str,typer.Argument(help="path of the auth-file for the cloud")]): #url:str,uid:str,token:str): """ - This function overwrites or sets token information for nextcloud. This function will load the content from the cloud instead of locally - - :url url of the nextcloud - :uid account identifier - :token token to be used to signin + Setup qcms to generate a site from files on nextcloud + The path must refrence auth-file (data-transport) """ if os.path.exists(path): f = open (path) @@ -182,13 +94,12 @@ def set_cloud(path:Annotated[str,typer.Argument(help="path of the auth-file for url = _auth['url'] if os.path.exists('qcms-manifest.json') : - _config = _get_config() + _config = config.get() _config['system']['source'] = path #{'id':'cloud','auth':{'url':url,'uid':uid,'token':token}} - write_config(_config) + config.write(_config) title = _config['layout']['header']['title'] - _msg = f""" - Successfully update, good job! - {url} + _msg = f"""{PASSED} Successfully update, good job! + {url} """ else: _msg = INVALID_FOLDER @@ -197,13 +108,15 @@ def set_cloud(path:Annotated[str,typer.Argument(help="path of the auth-file for else: _msg = INVALID_FOLDER print (_msg) -@cli.command(name='set-key') +@cli.command(name='secure') # def set_key(path): -def set_key( +def secure( manifest:Annotated[str,typer.Argument(help="path of the manifest file")], keyfile:Annotated[str,typer.Argument(help="path of the key file to generate")]): """ - create a security key to control the application during developement. The location must have been created prior. + Create a security key and set it in the manifest (and store in on disk). + The secure key allows for administrative control of the application/portal + """ if not os.path.exists(keyfile): @@ -211,11 +124,11 @@ def set_key( f.write(str(uuid.uuid4())) f.close() # - _config = _get_config(manifest) + _config = config.get(manifest) if 'source' not in _config['system']: _config['system']['source'] = {'id':'disk'} _config['system']['source']['key'] = keyfile - write_config(_config,manifest) + config.write(_config,manifest) _msg = f"""{PASSED} A key was generated and written to {keyfile} use this key in header to enable reload of the site ...` """ @@ -252,103 +165,186 @@ def load(**_args): return getattr(module,_name) if hasattr(module,_name) else None +@cli.command(name='plugins') +def plug_info (manifest:Annotated[str,typer.Argument(help="path to manifest file")], + show:bool=typer.Option(default=False,help="list plugins loaded"), + add: Annotated[Optional[bool],typer.Option("--register/--unregister",help="add/remove a plugin to manifest use with --pointer option")] = None, + pointer:str=typer.Option(default=None,help="pointer is structured as 'filename.function'") + ) : + """ + Manage plugins list loaded plugins, + """ + _config = config.get(manifest) + _root = os.sep.join(manifest.split(os.sep)[:-1] + [_config['layout']['root'],'_plugins']) + if os.path.exists(_root) : + # files = os.listdir(_root) + _msg = f"""{FAILED} no operation was specified, please use --help option""" + # if 'plugins' in _config : + _plugins = _config['plugins'] if 'plugins' in _config else {} + if show : + if not _plugins : + _msg = f"""{FAILED} no plugins are loaded\n\t{manifest}""" + else: + _data = plugins.stats(_plugins) + print (_data) + _msg = f"""{PASSED} found a total of {_data.loaded.sum()} plugins loaded from {_data.shape[0]} file(s)\n\t{_root}""" + + if add in [True,False] and pointer : + file,fnName = pointer.split('.') + _fnpointer = plugins.load(_root,file+'.py',fnName) + if _fnpointer and add: + if file not in _plugins : + _plugins[file] = [] + + if fnName not in _plugins[file] : + _plugins[file].append(fnName) + _msg = f"""{PASSED} registered {pointer}, use the --show option to list loaded plugins\n{manifest} """ + else: + _msg = f"""{FAILED} could not register {pointer}, it already exists\n\t{manifest} """ + elif add is False and file in _plugins: + _plugins[file] = [_name.strip() for _name in _plugins[file] if _name.strip() != fnName.strip() ] + + _msg = f"""{PASSED} unregistered {pointer}, use the --show option to list loaded plugins\n{manifest} """ + + # + # We need to write this down !! + if add in [True,False] : + + _config['plugins'] = _plugins + config.write(_config,manifest) + # else: + # _msg = f"""{FAILED} no plugins are loaded\n\t{manifest}""" + print() + print(_msg) + else: + _msg = f"""{FAILED} no plugin folder found """ + pass + + @cli.command (name='create') -# def create(folder:str,root:str,index:str='index.html',title:str='qcms',subtitle:str='',footer:str='Quick Content Management System',version:str='0.1'): def create(folder:Annotated[str,typer.Argument(help="path of the project folder")], - root:Annotated[str,typer.Argument(help="folder of the content (inside the project folder)")], + # root:Annotated[str,typer.Argument(help="folder of the content (inside the project folder)")], index:str=typer.Option(default="index.html",help="index file (markup or html)"), #Annotated[str,typer.Argument(help="index file to load (.html or markup)")]='index.html', title:str=typer.Option(default="QCMS PROJECT", help="name of the project") , #Annotated[str,typer.Argument(help="title of the project")]='QCMS - TITLE', subtitle:str=typer.Option(default="designed & built by The Phi Technology",help="tag line about the project (sub-title)"), #Annotated[str,typer.Argument(help="subtitle of the project")]='qcms - subtitle', - footer:str=typer.Option(default="Quick Content Management System",help="text to be placed as footer"), #Annotated[str,typer.Argument(help="text on the footer of the main page")]='Quick Content Management System', - version:str=typer.Option(default="0.2",help="version number") #Annotated[str,typer.Argument(help="version of the site")]='0.1' + footer:str=typer.Option(default="Powered By QCMS",help="text to be placed as footer"), #Annotated[str,typer.Argument(help="text on the footer of the main page")]='Quick Content Management System', + port:int=typer.Option(default=8084, help="port to be used for the app"), + context:str=typer.Option(default="", help="application context if using a proxy server"), + version:str=typer.Option(default=meta.__version__,help="version number") #Annotated[str,typer.Argument(help="version of the site")]='0.1' ): """ - This function will create a project folder by performing a git clone (pull the template project) + Create a project folder by performing a git clone (pull the template project) and adding a configuration file to that will bootup the project - - - folder project folder - - root name/location of root folder associated with the web application - - @TODO: - - add port, """ - #global SYS_ARGS - #folder = SYS_ARGS['create'] - - url = "https://dev.the-phi.com/git/cloud/cms.git" - #if 'url' in SYS_ARGS : - # url = SYS_ARGS['url'] - print ("\tcreating project into folder " +folder) - # - # if standalone, then we should create the git repository - # if standalone : - # _repo = Repo.clone_from(url,folder) - # # - # # removing remote folder associated with the project - # _repo.delete_remote(remote='origin') - # else: - # os.makedirs(folder) - - - # - # @TODO: root should be handled in - rootpath = os.sep.join([folder,root]) - _img = None - if not os.path.exists(rootpath) : - print (f"{PASSED} Creating content folder "+rootpath) - os.makedirs(rootpath) - os.makedirs(os.sep.join([rootpath,'_images'])) - _img = """data:image/png;base64,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""" - _,_img = _img.split(',',1) - logo = os.sep.join([rootpath,'_images','logo.png']) - f = open(logo,'wb') - f.write(base64.b64decode(_img)) - f.close() - _html = __doc__.replace("<","<").replace(">",">").replace("Usage","<br>Usage") - - if not os.path.exists(os.sep.join([rootpath,'index.html'])) : - f = open(os.sep.join([rootpath,'index.html']),'w') - f.write(_html) - f.close() - print (f'{PASSED} configuration being written to project folder') - _args = {'title':title,'subtitle':subtitle,'version':version,'root':root,'index':index} - if footer : - _args['footer'] = footer - _config = make(**_args) # - # updating logo reference (default for now) - _config['system']['logo'] = os.sep.join([_config['layout']['root'],'_images/logo.png']) - + # Build the configuration for the project + if not os.path.exists(folder) : + root = 'www/html' + _system = System() + _layout = Layout() + _layout = _layout.build(root=root, footer = [{"text":term} for term in footer.split(',')],header={'title':title,'subtitle':subtitle} ) + + _system = System() + _system = _system.build(version=version,port=port, context=context) + _config = dict(_system,**_layout) + print (f"{PASSED} Built Configuration Object") + # + # Setup Project on disk + # + project.make(folder=folder,config=_config) + print (f"""{PASSED} created project at {folder} """) + else: + print () + print (f"""{FAILED} Could not create project {folder} + The folder is not empty + """) - f = open(os.sep.join([folder,'qcms-manifest.json']),'w') - f.write(json.dumps(_config)) - f.close() - return _config @cli.command (name='reload') -def reload (path) : - if os.path.exists (path): - pass +def reload ( + path:Annotated[str,typer.Argument(help="")], + port:int=typer.Option(default=None,help="port of the host to call") + ) : + """ + Reload a site/portal given the manifest ... + """ + _config = config.get(path) + if 'key' in _config['system']['source'] : + f = open(_config['system']['source']['key']) + key = f.read() + f.close() + _port = port if port else _config['system']['app']['port'] + url = f"http://localhost:{_port}/reload" + resp = requests.post(url, headers={"key":key}) + if resp.status_code == 200 : + _msg = f"""{PASSED} successfully reloaded {url}""" + else: + _msg = f"""{FAILED} failed to reload, status code {resp.status_code}\n{url} + """ + else: + _msg = f"""{FAILED} no secure key found in manifest""" + print (_msg) @cli.command(name="bootup") def bootup ( - path:Annotated[str,typer.Argument(help="path of the manifest file")]='qcms-manifest.json', + manifest:Annotated[str,typer.Argument(help="path of the manifest file")]='qcms-manifest.json', port:int=typer.Option(default=None, help="port number to serve on (will override configuration)") ): """ This function will launch a site/project given the location of the manifest file """ - index.start(path,port) - # if not port : - # index.start(path) - # else: - # index.start(path,port) - -def reset(): + index.start(manifest,port) +@cli.command(name='theme') +def handle_theme ( + manifest:Annotated[str,typer.Argument(help="path of the manifest file")], + show:bool = typer.Option(default=False,help="print list of themes available"), + name:str = typer.Option(default='default',help='name of the theme to apply') + ) : """ + This function will show the list available themes and can also set a theme in a manifest + """ + _config = config.get(manifest) + _root = os.sep.join( manifest.split(os.sep)[:-1]+[_config['layout']['root']]) + + if show : + _df = pd.DataFrame({"available":themes.List()}) + if _df.shape[0] > 0 : + values = themes.installed(_root) + values = values + np.repeat(f"""{FAILED}""", _df.shape[0] - len(values)).tolist() + _df['installed'] = values + else: + _df = f"""{FAILED} No themes were found in registry,\ncurl {themes.URL}/api/themes/List (QCMS_HOST_URL should be set)""" + print (_df) + if name : + # we need to install the theme, i.e download it and update the configuration + # + try: + _object = themes.Get(name) + path = os.sep.join([_root,'_assets','themes',name]) + if not os.path.exists(path) : + os.makedirs(path) + _object = _object[name] #-- formatting (...) + for _filename in _object : + css = _object[_filename] + f = open(os.sep.join([path,_filename+'.css']),'w') + f.write(css) + f.close() + + # + # Let us update the configuration file ... + # + _config['system']['theme'] = name + config.write(_config,manifest) + _msg = f"""{PASSED} successfully downloaded {name} \n{PASSED} updated manifest {manifest} + """ + except Exception as e: + _msg = f"""{FAILED} operation failed "{str(e)}" + """ + + pass + print (_msg) + global SYS_ARGS if __name__ == '__main__': cli() diff --git a/cms/__init__.py b/cms/__init__.py index 3f98a0b..5fe6631 100644 --- a/cms/__init__.py +++ b/cms/__init__.py @@ -6,192 +6,62 @@ import copy from jinja2 import Environment, BaseLoader, FileSystemLoader import importlib import importlib.util -# from cms import disk, cloud, engine -# import cloud -# import index -# class components : -# # @staticmethod -# # def folders (_path): -# # """ -# # This function reads the content of a folder (no depth, it must be simple) -# # """ -# # _content = os.listdir(_path) -# # return [_name for _name in _content if os.path.isdir(os.sep.join([_path,_name])) if not _name.startswith('_')] -# # @staticmethod -# # def content (_folder) : -# # if os.path.exists(_folder) : -# # # return [{'text':_name.split('.')[0].replace('_', ' ').replace('-',' ').strip(),'uri': os.sep.join([_folder,_name])} for _name in os.listdir(_folder) if not _name.startswith('_') and os.path.isfile( os.sep.join([_folder,_name]))] -# # return [{'text':_name.split('.')[0].replace('_', ' ').replace('-',' ').strip(),'uri': os.sep.join([_folder,_name])} for _name in os.listdir(_folder) if not _name.startswith('_') and os.path.isfile( os.sep.join([_folder,_name]))] -# # else: -# # return [] -# @staticmethod -# def menu(_config): -# """ -# This function will read menu and sub-menu items from disk structure, -# The files are loaded will -# """ -# # _items = components.folders(_path) - -# # _layout = copy.deepcopy(_config['layout']) -# # _overwrite = _layout['overwrite'] if 'overwrite' in _layout else {} -# # -# # content of each menu item -# # _subItems = [ components.content (os.sep.join([_path,_name]))for _name in _items ] -# # if 'map' in _layout : -# # _items = [_name if _name not in _layout['map'] else _layout['map'][_name] for _name in _items] - -# # _object = dict(zip(_items,_subItems)) - -# if 'source' in _config['system'] and _config['system']['source']['id'] == 'cloud' : -# _sourceHandler = cloud -# else: -# _sourceHandler = disk -# _object = _sourceHandler.build(_config) -# # _object = disk.build(_path,_config) if type(_path) == str else cloud.build(_path,_config) -# _layout = copy.deepcopy(_config['layout']) -# _overwrite = _layout['overwrite'] if 'overwrite' in _layout else {} - -# # -# # @TODO: Find a way to translate rename/replace keys of the _object (menu) here -# # -# #-- applying overwrites to the menu items -# for _name in _object : -# _submenu = _object[_name] -# _index = 0 -# for _item in _submenu : -# text = _item['text'].strip() - -# if text in _overwrite : -# if 'uri' in _item and 'url' in 'url' in _overwrite[text] : -# del _item['uri'] -# _item = dict(_item,**_overwrite[text]) -# if 'uri' in _item: -# _item['uri'] = _item['uri'].replace(_layout['root'],'') -# _submenu[_index] = _item -# _index += 1 -# return _object - -# @staticmethod -# def html(uri,id,_args={},_system={}) : -# """ -# This function reads a given uri and returns the appropriate html document, and applies environment context -# """ -# if 'source' in _system and _system['source']['id'] == 'cloud': -# _html = cloud.html(uri,dict(_args,**{'system':_system})) - -# else: -# _html = disk.html(uri) -# # _html = (open(uri)).read() +# def _get_config (path) : +# if os.path.exists(path) : +# f = open(path) +# _conf = json.loads(f.read()) +# f.close() +# else: +# _conf = {} +# return _conf +# def _isvalid(_allowed,**_args): + +# if not list(set(_allowed) - set(_args.keys())) : +# _pargs = {} +# for key in _allowed : +# _pargs [key] = _args[key] +# return _pargs +# return False + +# def write_config(_config, path): +# f = open(path,'w') +# f.write( json.dumps(_config)) ; +# f.close() -# #return ' '.join(['<div id=":id" class=":id">'.replace(':id',id),_html,'</div>']) -# _html = ' '.join(['<div id=":id" class=":id">'.replace(':id',id),_html,'</div>']) -# appContext = Environment(loader=BaseLoader()).from_string(_html) -# # -# # If the rendering of the HTML happens here we should plugin custom functions (at the very least) -# # - -# return appContext.render(**_args) -# # return _html -# @staticmethod -# def data (_args): -# """ -# :store data-store parameters (data-transport, github.com/lnyemba/data-transport) -# :query query to be applied against the store (expected data-frame) -# """ -# _store = _args['store'] -# reader = transport.factory.instance(**_store) -# _queries= copy.deepcopy(_store['query']) -# _data = reader.read(**_queries) -# return _data -# @staticmethod -# def csv(uri) : -# return pd.read(uri).to_html() -# @staticmethod -# def load_plugin(**_args): -# """ -# This function will load external module form a given location and return a pointer to a function in a given module -# :path absolute path of the file (considered plugin) to be loaded -# :name name of the function to be applied -# """ -# _path = _args['path'] #os.sep.join([_args['root'],'plugin']) -# if os.path.isdir(_path): -# files = os.listdir(_path) -# if files : -# files = [name for name in files if name.endswith('.py')] -# if files: -# _path = os.sep.join([_path,files[0]]) -# else: -# return None -# else: -# return None -# #-- We have a file ... -# _name = _args['name'] - -# spec = importlib.util.spec_from_file_location(_name, _path) -# module = importlib.util.module_from_spec(spec) -# spec.loader.exec_module(module) - -# return getattr(module,_name) if hasattr(module,_name) else None -# @staticmethod -# def plugins(_config) : -# """ -# This function looks for plugins in the folder on disk (no cloud support) and attempts to load them -# """ -# PATH= os.sep.join([_config['layout']['root'],'_plugins']) -# _map = {} -# # if not os.path.exists(PATH) : -# # return _map -# if 'plugins' not in _config : -# _config['plugins'] = {} -# _conf = _config['plugins'] - -# for _key in _conf : - -# _path = os.sep.join([PATH,_key+".py"]) -# if not os.path.exists(_path): -# continue -# for _name in _conf[_key] : -# _pointer = components.load_plugin(path=_path,name=_name) -# if _pointer : -# _uri = "/".join(["api",_key,_name]) -# _map[_uri] = _pointer -# # -# # We are adding some source specific plugins to the user-defined plugins -# # This is intended to have out-of the box plugins... -# # -# if 'source' in _config['system'] and _config['system']['source']['id'] == 'cloud' : -# _plugins = cloud.plugins() -# else: -# _plugins = disk.plugins() -# # -# # If there are any plugins found, we should load them and use them +# def _system(**_args): +# """ +# Both version and context must be provided otherwise they are ignored +# :version +# :context context +# """ +# _info = _isvalid(['version','context'],**_args) +# _info['theme'] = 'default.css' +# _info = _info if _info else {'version':'0.0.0','context':'','theme':'default.css'} +# if _info : +# _info['logo'] = None if 'logo' not in _args else _args['logo'] +# # +# # There is an aggregation entry here in app +# _appInfo = {'debug':True,'port':8084,'threaded':True,'host':'0.0.0.0'} +# if 'app' not in _args: +# _info['app'] = _appInfo +# else: +# _info['app'] = dict(_appInfo,**_args['app']) -# if _plugins : -# _map = dict(_map,**_plugins) -# return _map -# @staticmethod -# def context(_config): -# """ -# adding custom variables functions to Jinja2, this function should be called after plugins are loaded -# """ -# _plugins = _config['plugins'] -# # if not location: -# # env = Environment(loader=BaseLoader()) -# # else: -# location = _config['layout']['root'] -# # env = Environment(loader=FileSystemLoader(location)) -# env = Environment(loader=BaseLoader()) -# # env.globals['routes'] = _config['plugins'] -# return env -# @staticmethod -# def get_system(_config,skip_keys=[]): -# _system = copy.deepcopy(_config['system']) -# if skip_keys : -# for key in skip_keys : -# if key in _system : -# del _system -# return _system - +# return _info +# def _header(**_args): +# return _isvalid(['logo','title','subtitle'],**_args) +# def _layout(**_args): +# _info = _isvalid(['root','index'],**_args) +# _info['on'] = {"load":{},"error":{}} +# _url = 'qcms.co' +# _overwrite = {"folder1":{"type":"redirect","url":_url},"folder2":{"type":"dialog"},"folder3":{"type":"dialog","url":_url}} +# _info['icons'] = {"comment":"use folder names as keys and fontawesome type as values to add icons to menu"} +# _info["api"] = {"comment":"use keys as uri and function calls as values"} +# _info['map'] = {}, +# _info['order'] = {'menu':[]} +# _info['overwrite'] = _overwrite +# return _info + \ No newline at end of file diff --git a/cms/cloud.py b/cms/cloud.py index 8a7b625..8b96573 100644 --- a/cms/cloud.py +++ b/cms/cloud.py @@ -128,13 +128,15 @@ def html (uri,_config) : _html = _handler.get_file_contents(uri).decode('utf-8')#.replace('.attachments.', copy.deepcopy(_link)) # print ([uri,uri[-2:] ,uri[-2:] in ['md','MD','markdown']]) _handler.logout() + # if uri.endswith('.md'): - if not _context : - _html = _html.replace(_root,('api/cloud/download?doc='+_root)).replace('.attachments.', copy.deepcopy(_link)) - else: - _html = _html.replace(_root,(f'{_context}api/cloud/download?doc='+_root)).replace('.attachments.', copy.deepcopy(_link)) + if f"{_root}{os.sep}" in _html : + if not _context : + _html = _html.replace(_root,('api/cloud/download?doc='+_root)).replace('.attachments.', copy.deepcopy(_link)) + else: + _html = _html.replace(_root,(f'{_context}api/cloud/download?doc='+_root)).replace('.attachments.', copy.deepcopy(_link)) # _html = _html.replace('<br />','') - return markdown(_html) if uri[-2:] in ['md','MD','Md','mD'] else _html + return markdown(_html).replace(""",'"').replace("<","<").replace(">",">") if uri[-2:] in ['md','MD','Md','mD'] else _html # def update (_config): # """ # This function updates the configuration provided by loading default plugins diff --git a/cms/disk.py b/cms/disk.py index 4426be5..8a3b8f6 100644 --- a/cms/disk.py +++ b/cms/disk.py @@ -6,7 +6,7 @@ import importlib import importlib.util import copy from mistune import markdown - +import re def folders (_path,_config): """ @@ -86,13 +86,22 @@ def read (**_args): _uri = request.args['uri'] # if 'location' in _layout : # _uri = os.sep.join([_layout['location'],_uri]) _uri = _realpath(_uri, _args['config']) + _mimeType = 'text/plain' if os.path.exists(_uri): f = open(_uri,mode='rb') _stream = f.read() f.close() - - return _stream - return None + # + # Inferring the type of the data to be returned + _mimeType = 'application/octet-stream' + _extension = _uri.split('.')[-1] + if _extension in ['css','js','csv'] : + _mimeType = f'text/{_extension}' + elif _extension in ['png','jpg','jpeg'] : + _mimeType = f'image/{_extension}' + + return _stream, _mimeType + return None,_mimeType def exists(**_args): _path = _realpath(_args['uri'],_args['config']) @@ -111,9 +120,10 @@ def html(_uri,_config) : _api = os.sep.join(['api/disk/read?uri=',_layout['root']]) else: _api = os.sep.join([f'{_context}/api/disk/read?uri=',_layout['root']]) - - _html = _html.replace(_layout['root'],_api) - _html = markdown(_html) if _uri[-2:] in ['md','MD','Md','mD'] else _html + if f"{_layout['root']}{os.sep}" in _html : + _html = _html.replace(f"{_layout['root']}",_api) + _html = markdown(_html).replace(""",'"').replace("<","<").replace(">",">") if _uri[-2:] in ['md','MD','Md','mD'] else _html + return _html def plugins (**_args): """ diff --git a/cms/index.py b/cms/index.py index c090e79..7e002b6 100644 --- a/cms/index.py +++ b/cms/index.py @@ -77,38 +77,21 @@ def robots_txt(): return Response('\n'.join(_info), mimetype='text/plain') @_app.route("/") def _index (): - # global _config - # global _route - # _handler = _route.get() - # _config = _route.config() _handler = _getHandler() _config = _handler.config() + global _route + print ([' serving ',session.get('app_id','NA'),_handler.layout()['root']]) - # _system = _handler.system() - # _plugins= _handler.plugins() - # _args = {} - # # if 'plugins' in _config : - # # _args['routes']=_config['plugins'] - # # _system = cms.components.get_system(_config) #copy.deepcopy(_config['system']) - # _html = "" _args={'system':_handler.system(skip=['source','app','data']),'layout':_handler.layout()} try: - uri = os.sep.join([_config['layout']['root'], _config['layout']['index']]) - - # # _html = _route.get().html(uri,'index',_config,_system) - # _html = _handler.html(uri,'index') + uri = os.sep.join([_config['layout']['root'], _config['layout']['index']]) _index_page = "index.html" + print ([_route]) _args = _route.render(uri,'index',session.get('app_id','main')) except Exception as e: # print () print (e) _index_page = "404.html" - # _args['uri'] = request.base_url - # pass - # # if 'source' in _system : - # # del _system['source'] - # _args = {'layout':_config['layout'],'index':_html} - # _args['system'] = _handler.system(skip=['source','app','route']) return render_template(_index_page,**_args),200 if _index_page != "404.html" else 200 @@ -139,7 +122,7 @@ def _delegate_call(app,module,name): return _delegate(_handler,module,name) @_app.route('/api/<module>/<name>') -def _getproxy(module,name) : +def _api(module,name) : """ This endpoint will load a module and make a function call :_module entry specified in plugins of the configuration @@ -171,13 +154,15 @@ def _delegate(_handler,module,name): # else: # _data = pointer() - _data = pointer(request=request,config=_handler.config()) + _data,_mimeType = pointer(request=request,config=_handler.config()) + + _mimeType = 'application/octet-stream' if not _mimeType else _mimeType if type(_data) == pd.DataFrame : _data = _data.to_dict(orient='records') if type(_data) == list: _data = json.dumps(_data) _code = 200 if _data else 500 - return _data,_code + return _data,_code,{'Content-Type':_mimeType} @_app.route("/api/<module>/<name>" , methods=['POST']) def _post (module,name): # global _config diff --git a/cms/plugins.py b/cms/plugins.py deleted file mode 100644 index e34862b..0000000 --- a/cms/plugins.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -These are a few default plugins that will be exported and made available to the calling code -The purpose of plugins is to perform data processing -""" -import json - - - -def copyright (_args) : - return {"author":"Steve L. Nyemba","email":"steve@the-phi.com","organization":"The Phi Technology","license":"MIT", "site":"https://dev.the-phi.com/git/cloud/qcms"} - -def log (_args): - """ - perform logging against duckdb - """ - pass \ No newline at end of file diff --git a/cms/static/css/themes/oss.css b/cms/static/css/themes/oss.css index 8818714..839d7d6 100644 --- a/cms/static/css/themes/oss.css +++ b/cms/static/css/themes/oss.css @@ -7,6 +7,8 @@ margin-left:10%; margin-right:10%; gap:4px; + + } .main .header { height:64px; display:grid; diff --git a/cms/static/css/themes/resume.css b/cms/static/css/themes/resume.css index d626468..3a06a0e 100644 --- a/cms/static/css/themes/resume.css +++ b/cms/static/css/themes/resume.css @@ -8,10 +8,11 @@ .main .header { display:grid; grid-template-columns: 64px auto; gap:4px; + align-items: center; } .main .header .title {font-weight:bold; font-size:24px; text-transform: capitalize;} .main .header .subtitle {font-size:14px; text-transform: capitalize;} .main .header img {height:64px; width:64px} -.main .menu {display:none} +.main .menu {margin-left:200px} .pane {width:50%; height:100px; background-color: pink;} \ No newline at end of file diff --git a/cms/templates/header.html b/cms/templates/header.html index 128e314..26428a6 100644 --- a/cms/templates/header.html +++ b/cms/templates/header.html @@ -1,11 +1,10 @@ -{% if layout.header.logo == True %} - <div class="icon"> - <img src="{{system.icon}}"> - </div> + +<div class="icon"> + <img src="{{system.icon}}"> +</div> -{% endif %} - <div> - <div class="title">{{layout.header.title}}</div> - <div class="subtitle">{{layout.header.subtitle}}</div> - </div> \ No newline at end of file +<div> + <div class="title">{{layout.header.title}}</div> + <div class="subtitle">{{layout.header.subtitle}}</div> +</div> \ No newline at end of file diff --git a/cms/templates/index.html b/cms/templates/index.html index 2486f08..a99be3d 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -23,14 +23,27 @@ Vanderbilt University Medical Center <meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="keywords" content="quick cms, cms, python, flask, qcms"> <meta name="robots" content="/, follow, max-snippet:-1, max-image-preview:large"> - <link href="{{system.context}}/static/css/default.css" rel="stylesheet" type="text/css"> - <link href="{{system.context}}/static/css/menu.css" rel="stylesheet" type="text/css"> - <link href="{{system.context}}/static/css/border.css" rel="stylesheet" type="text/css"> + <!-- <link href="{{system.context}}/static/css/default.css" rel="stylesheet" type="text/css"> --> + <!-- <link href="{{system.context}}/static/css/menu.css" rel="stylesheet" type="text/css"> --> + <!-- <link href="{{system.context}}/static/css/border.css" rel="stylesheet" type="text/css"> --> <!-- <link href="{{system.context}}/static/css/animation/_ocean.css" rel="stylesheet" type="text/css"> --> - <link href="{{system.context}}/static/css/themes/{{system.theme}}" rel="stylesheet" type="text/css"> - <link href="{{system.context}}/static/css/icons.css" rel="stylesheet" type="text/css"> + <!-- <link href="{{system.context}}/static/css/themes/{{system.theme}}" rel="stylesheet" type="text/css"> --> + <!-- <link href="{{system.context}}/static/css/icons.css" rel="stylesheet" type="text/css"> --> + <link href="{{system.context}}/static/css/icons.css" rel="stylesheet" type="text/css"> + <link href="{{system.context}}/static/css/source-code.css" rel="stylesheet" type="text/css"> + <link href="{{system.context}}/static/css/search.css" rel="stylesheet" type="text/css"> + <link href="{{system.context}}/static/css/dialog.css" rel="stylesheet" type="text/css"> + + <!-- applying themes as can --> + <link href="{{system.context}}/api/disk/read?uri={{layout.root}}/_assets/themes/{{system.theme}}/layout.css" rel="stylesheet" type="text/css"> + <link href="{{system.context}}/api/disk/read?uri={{layout.root}}/_assets/themes/{{system.theme}}/header.css" rel="stylesheet" type="text/css"> + <link href="{{system.context}}/api/disk/read?uri={{layout.root}}/_assets/themes/{{system.theme}}/menu.css" rel="stylesheet" type="text/css"> + <link href="{{system.context}}/api/disk/read?uri={{layout.root}}/_assets/themes/{{system.theme}}/borders.css" rel="stylesheet" type="text/css"> + <link href="{{system.context}}/api/disk/read?uri={{layout.root}}/_assets/themes/{{system.theme}}/footer.css" rel="stylesheet" type="text/css"> + <link href="{{system.context}}/api/disk/read?uri={{layout.root}}/_assets/themes/{{system.theme}}/pane.css" rel="stylesheet" type="text/css"> + <script src="{{system.context}}/static/js/jx/dom.js"></script> <script src="{{system.context}}/static/js/jx/utils.js"></script> <script src="{{system.context}}/static/js/jx/rpc.js"></script> diff --git a/meta/__init__.py b/meta/__init__.py index 1a6b54e..e6d5dc9 100644 --- a/meta/__init__.py +++ b/meta/__init__.py @@ -1,4 +1,15 @@ -__author__ = "Steve L. Nyemba<steve@the-phi.com>" -__version__= "2.0" +__author__ = "Steve L. Nyemba" +__version__= "2.1" +__email__ = "steve@the-phi.com" __license__=""" +Copyright 2010 - 2024, Steve L. Nyemba, Vanderbilt University Medical Center + + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """