diff --git a/index.html b/index.html index 33cc08be..1b3f759e 100755 --- a/index.html +++ b/index.html @@ -806,7 +806,7 @@

Vous avez terminé la
- +
diff --git a/js/mviewerstudio.js b/js/mviewerstudio.js index 472f7cef..942708ec 100644 --- a/js/mviewerstudio.js +++ b/js/mviewerstudio.js @@ -259,7 +259,6 @@ var newConfiguration = function (infos) { id: infos?.id || mv.uuid(), description: newDate.format("DD-MM-YYYY-HH-mm-ss"), isFile: !!infos?.id, - publish: infos?.publish == 'true', relation: infos?.relation }; //Store des parametres non gérés @@ -915,6 +914,7 @@ var saveApplicationParameters = () => { config.isFile = true; document.querySelector("#toolsbarStudio-delete").classList.remove("d-none"); document.querySelector("#layerOptionBtn").classList.remove("d-none"); + mv.manageDraftBadge(config.relation); } else { config.isFile = false; } diff --git a/lib/mv.js b/lib/mv.js index ee253f8d..0681e943 100755 --- a/lib/mv.js +++ b/lib/mv.js @@ -1129,6 +1129,7 @@ var mv = (function () { var publisher = "anonymous"; var organisation = _userInfo?.groupFullName || ""; var description = document.querySelector("#createVersionInput")?.value || data.description + let relation = data?.relation || ""; const UUID = data.id; const keyworkds = document.querySelector("#optKeywords")?.value; @@ -1217,13 +1218,7 @@ var mv = (function () { }); }, - parseApplication(xml) { - const app_identifier = xml.getElementsByTagName("dc:identifier")[0]?.innerHTML - const app_keywords = xml.getElementsByTagName("dc:keywords")[0]?.innerHTML; - const dateXml = xml.getElementsByTagName("dc:date")[0]?.innerHTML; - const relation = xml.getElementsByTagName("dc:relation")[0]?.innerHTML; - const isPublish = xml.getElementsByTagName("config")[0].getAttribute("publish") == "true"; - + manageDraftBadge(isPublish) { if (isPublish) { document.querySelector("#toolsbarStudio-unpublish").classList.remove("d-none"); document.querySelector(".badge-publish").classList.remove("d-none"); @@ -1233,11 +1228,20 @@ var mv = (function () { document.querySelector(".badge-publish").classList.add("d-none"); document.querySelector(".badge-draft").classList.remove("d-none"); } + }, + + parseApplication(xml) { + const app_identifier = xml.getElementsByTagName("dc:identifier")[0]?.innerHTML + const app_keywords = xml.getElementsByTagName("dc:keywords")[0]?.innerHTML; + const dateXml = xml.getElementsByTagName("dc:date")[0]?.innerHTML; + const relation = xml.getElementsByTagName("dc:relation")[0]?.innerHTML; + + mv.manageDraftBadge(relation) if (_conf.is_php && onlineCard) { onlineCard.classList.add("d-none") } - newConfiguration({id: app_identifier, isFile: true, date: dateXml, publish: isPublish, relation: relation}); + newConfiguration({id: app_identifier, isFile: true, date: dateXml, relation: relation}); var proxy = $(xml).find("proxy"); var olscompletion = $(xml).find("olscompletion"); if (proxy) { @@ -1511,8 +1515,8 @@ var mv = (function () { ` }]; - let badgeLabel = app.publish == "true" ? mviewer.tr("publish") : mviewer.tr("draft"); - let badgeColor = app.publish == "true" ? "badge-publish" : "badge-draft"; + let badgeLabel = app.relation ? mviewer.tr("publish") : mviewer.tr("draft"); + let badgeColor = app.relation ? "badge-publish" : "badge-draft"; let badge = _conf.is_php ? "" : `${ badgeLabel }`; const items = `
@@ -1961,6 +1965,49 @@ var mv = (function () { 'data-url':row.xml } }, + nameNormalizer: (str="") => { + return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").replace(/[^\w ]/g, '_').replace(/\s/g, '_').toLowerCase() + }, + onChangeName: ({value}, defaultValue) => { + document.querySelector("#sendPublishApp").disabled = conflict && v === defaultName; + }, + showNamePublishModal: (id, name = "", conflict = false) => { + if (config.relation) { + return mv.publish(config.id, config.relation) + } + const defaultName = name ? mv.nameNormalizer(name) : mv.nameNormalizer(document.querySelector("#opt-title").value); + const publishAppModal = new bootstrap.Modal('#genericModal'); + const question = conflict ? "Ce nom existe déjà !
Vous pouvez changer le nom ou annuler." : "Quel nom souhaitez vous utiliser pour la publication ?" + genericModalContent.innerHTML = ""; + genericModalContent.innerHTML = ` + +
+ `; + publishAppModal.show(); + }, showPublishModal: (shareLink = "", iframeLink = "", draftLink = "") => { const publishModal = new bootstrap.Modal('#genericModal'); genericModalContent.innerHTML = ""; @@ -1999,16 +2046,16 @@ var mv = (function () { `; publishModal.show(); }, - publish: (id) => { + publish: (id, name = "") => { if (!id) { return alertCustom("L'ID n'est pas renseigné. Veuillez contacter un administrateur.", "danger"); } if (!config.isFile) { return alertCustom("Enregistrez une premère fois avant de publier !", "danger"); } - fetch(`${ _conf.api }/${id}/publish`) + fetch(`${ _conf.api }/${id}/publish/${name}`,) .then(r => { - return r.ok ? r.json() : Promise.reject(r) + return r.ok ? r.json() : Promise.reject(r); }) .then(data => { if (!_conf?.mviewer_publish) { @@ -2026,7 +2073,11 @@ var mv = (function () { alertCustom("L'application a bien été publiée !", "success"); }) .catch(err => { - alertCustom("Une erreur s'est produite. Veuillez contacter un administrateur.", "danger"); + if (err.status == 409) { + mv.showNamePublishModal(id, name, true); + } else { + alertCustom("Une erreur s'est produite. Veuillez contacter un administrateur.", "danger"); + } }) }, refreshOnPublish: (file) => { @@ -2034,7 +2085,7 @@ var mv = (function () { loadApplicationParametersFromRemoteFile(url); }, unpublish: (id) => { - fetch(`${ _conf.api }/${ id }/publish`, { method: "DELETE" }) + fetch(`${ _conf.api }/${ id }/publish/${config.relation}`, { method: "DELETE" }) .then(r => { return r.ok ? r.json() : Promise.reject(r) }) diff --git a/mviewerstudio.i18n.json b/mviewerstudio.i18n.json index 45610814..f306b41e 100644 --- a/mviewerstudio.i18n.json +++ b/mviewerstudio.i18n.json @@ -147,6 +147,8 @@ "tabs.publication.preview_title" : "Prévisualiser votre application", "tabs.publication.preview_text" : "Tester et valider votre configuration en live", "tabs.publication.publish_title" : "Publier votre application", + "tabs.publication.publish_retry" : "Publier avec ce nouveau nom", + "tabs.publication.publish_replace": "Remplacer", "tabs.publication.publish_text" : "Mettre en ligne votre application et obtenez un lien de partage", "tabs.data.title": "Thématiques & données", "tabs.data.themespanel.title": "Panneau des thématiques", @@ -530,6 +532,8 @@ "version.comment.ph": "ex: Test release without data", "studio.toolsbar.unpublish": "Unpublish", "tabs.publication.publish_title" : "Publish application", - "tabs.publication.publish_text" : "Put your application online." + "tabs.publication.publish_text" : "Put your application online.", + "tabs.publication.publish_retry": "Retry with this new name", + "tabs.publication.publish_replace": "Replace" } } diff --git a/srv/python/mviewerstudio_backend/models/config.py b/srv/python/mviewerstudio_backend/models/config.py index 96e2dd08..8e533722 100644 --- a/srv/python/mviewerstudio_backend/models/config.py +++ b/srv/python/mviewerstudio_backend/models/config.py @@ -21,7 +21,7 @@ class ConfigModel: publisher: str subject: str date: str - publish: bool + relation: str def as_dict(self): return { @@ -35,5 +35,5 @@ def as_dict(self): "url": self.url, "subject": self.subject, "date": self.date, - "publish": self.publish + "relation": self.relation } diff --git a/srv/python/mviewerstudio_backend/route.py b/srv/python/mviewerstudio_backend/route.py index 1ba77928..65fd1982 100644 --- a/srv/python/mviewerstudio_backend/route.py +++ b/srv/python/mviewerstudio_backend/route.py @@ -1,6 +1,6 @@ from flask import Blueprint, jsonify, Response, request, current_app, redirect from .utils.login_utils import current_user -from .utils.config_utils import Config +from .utils.config_utils import Config, edit_xml_string, read_xml_file_content from .utils.commons import clean_preview, init_preview import hashlib, uuid from os import path, mkdir, remove @@ -11,7 +11,7 @@ from .utils.git_utils import Git_manager from .utils.register_utils import from_xml_path -from werkzeug.exceptions import BadRequest, MethodNotAllowed +from werkzeug.exceptions import BadRequest, MethodNotAllowed, Conflict import logging @@ -126,8 +126,8 @@ def list_stored_mviewer_config() -> Response: config["url"] = current_app.config["CONF_PATH_FROM_MVIEWER"] + config["url"] return jsonify(configs) -@basic_store.route("/api/app//publish", methods=["GET", "DELETE"]) -def publish_mviewer_config(id) -> Response: +@basic_store.route("/api/app//publish/", methods=["GET", "DELETE"]) +def publish_mviewer_config(id, name) -> Response: """ Will put online a config. This route will copy / past XML to publication directory or delete to unpublish. @@ -135,35 +135,58 @@ def publish_mviewer_config(id) -> Response: """ logger.debug("PUBLISH : %s " % id) + xml_publish_name = name + + # control publish directory exists publish_dir = current_app.config["MVIEWERSTUDIO_PUBLISH_PATH"] if not publish_dir or not path.exists(publish_dir): return BadRequest("Publish directory does not exists !") + # create or get org parent directory from publication path + org_publish_dir = path.join(current_app.publish_path, current_user.organisation) + if not path.exists(org_publish_dir): + mkdir(org_publish_dir) + + # control file to create or replace + past_file = path.join(org_publish_dir, "%s.xml" % xml_publish_name) + if path.exists(past_file) and request.method == "GET": + # read file to replace + content = read_xml_file_content(past_file) + org = content.find(".//metadata/{*}RDF/{*}Description//{*}publisher").text + creator = content.find(".//metadata/{*}RDF/{*}Description//{*}creator").text + identifier = content.find(".//metadata/{*}RDF/{*}Description//{*}identifier").text + lastRelation = content.find(".//metadata/{*}RDF/{*}Description//{*}relation").text + # detect conflict + if lastRelation != xml_publish_name or org != current_user.organisation or creator != current_user.username or identifier != id: + return Conflict("Already exists !") + # replace safely or return bad request + remove(past_file) + # control that workspace to copy exists workspace = path.join(current_app.config["EXPORT_CONF_FOLDER"], current_user.organisation, id) - if not path.exists(workspace): return BadRequest("Application does not exists !") - - config = current_app.register.read_json(id) + # read config if exists + config = current_app.register.read_json(id) if not config: raise BadRequest("This config doesn't exists !") copy_file = current_app.config["EXPORT_CONF_FOLDER"] + config[0]["url"] config = from_xml_path(current_app, copy_file) - past_file = path.join(current_app.publish_path, "%s.xml" % id) - # add publish info in XML if request.method == "GET": - config.xml.set("publish", "true") + edit_xml_string(config.meta, "relation", xml_publish_name) message = "publish" # add unpublish info in XML if request.method == "DELETE": - config.xml.set("publish", "false") + edit_xml_string(config.meta, "relation", "") + remove(past_file) message = "Unpublish" + past_file = None + # will update XML with correct relation value to map publish and draft files config.write() # commit to track this action @@ -172,15 +195,10 @@ def publish_mviewer_config(id) -> Response: # update JSON config.register.update_from_id(id) - if path.exists(past_file): - remove(past_file) - # move to publish directory if request.method == "GET": copyfile(copy_file, past_file) - if request.method == "DELETE": - past_file=None draft_file = current_app.config["CONF_PATH_FROM_MVIEWER"] + config.as_dict()["url"] return jsonify({"online_file": past_file, "draft_file": draft_file}) @@ -212,7 +230,13 @@ def delete_config_workspace(id = None) -> Response: if current_user.username == "anonymous" and config[0]["publisher"] != current_app.config["DEFAULT_ORG"]: logger.debug("DELETE : NOT ALLOWED FOR THIS ANONYMOUS USER - ORG IS NOT DEFAULT") return MethodNotAllowed("Not allowed !") - + # delete publish + if "relation" in config[0] and config[0]["relation"]: + org_publish_dir = path.join(current_app.publish_path, current_user.organisation) + publish_file = path.join(org_publish_dir, "%s.xml" % config[0]["relation"]) + if path.exists(publish_file): + remove(publish_file) + # delete in json current_app.register.delete(id) # delete dir diff --git a/srv/python/mviewerstudio_backend/utils/config_utils.py b/srv/python/mviewerstudio_backend/utils/config_utils.py index 348bcf83..9ffe67d0 100644 --- a/srv/python/mviewerstudio_backend/utils/config_utils.py +++ b/srv/python/mviewerstudio_backend/utils/config_utils.py @@ -1,4 +1,4 @@ -from os import path, makedirs +from os import path, makedirs, remove from unidecode import unidecode import logging import xml.etree.ElementTree as ET @@ -12,23 +12,69 @@ logger = logging.getLogger(__name__) -def getWorkspace(app, path_id = ""): - return path.join(app.config["EXPORT_CONF_FOLDER"], path_id) +def getWorkspace(app, end_path = ""): + ''' + Retrieve workspace from path + :param app: current Flask app instance + :param path_id: path + ''' + if not end_path : return + return path.join(app.config["EXPORT_CONF_FOLDER"], end_path) def edit_xml_string(root, attribute, value): + ''' + Tool to find and write inside XML node + :param root: xml or xml root node object (e.g self.meta in Config class) + :param attribute: string xml node name to change in root node (e.g "{*}relation") + :param value: string value to insert in XML node + ''' attr = root.find(".//{*}%s" % attribute) attr.text = value def write_file(xml, xml_path): + ''' + Will write an xml into file + :param xml: xml to write in xml path file + :param xml_path: string xml file path + ''' xml_to_string = ET.tostring(xml).decode("utf-8") file = open(xml_path, "w") file.write(xml_to_string) file.close() +def clean_xml_from_dir(path): + ''' + Remove each XML found in dir + :param path: string xml file path + ''' + for file in glob.glob("%s/*.xml" % path): + remove(file) + +def read_xml_file_content(path, node = ""): + ''' + Read XML from path and could return xml node or xml as parser + :param path: string xml file path + ''' + fileToReplace = open(path, "r") + xml_str = fileToReplace.read() + xml_parser = ET.fromstring(xml_str) + if node : + return xml_parser.find(node) + else: + return xml_parser + +def control_relation(path, relation): + content = read_xml_file_content(path) + org = content.find(".//metadata/{*}RDF/{*}Description//{*}publisher").text + creator = content.find(".//metadata/{*}RDF/{*}Description//{*}creator").text + identifier = content.find(".//metadata/{*}RDF/{*}Description//{*}identifier").text + lastRelation = content.find(".//metadata/{*}RDF/{*}Description//{*}relation").text + + ''' This class ease git repo manipulations. A register from store/register.json is use as global configs metadata store. -DCAT-RDF Metadata are given by front end (see above ConfigModel). +DCAT-RDF Metadata are given by front end (see imported ConfigModel). ''' class Config: def __init__(self, data = "", app = None, xml = None) -> None: @@ -108,16 +154,12 @@ def _get_xml_describe(self, xml): edit_xml_string(meta_root, "creator", current_user.username) if current_user and current_user.organisation: edit_xml_string(meta_root, "publisher", current_user.organisation) - if meta_root.find(".//{*}relation") is not None: - decode_file_name = unidecode(meta_root.find(".//{*}relation").text) - normalize_file_name = re.sub('[^a-zA-Z0-9 \n\.]', "_", decode_file_name).replace(" ", "_") - edit_xml_string(meta_root, "relation", normalize_file_name) return meta_root def write(self): write_file(self.xml, self.full_xml_path) - def create_or_update_config(self, file): + def create_or_update_config(self, file = None): ''' Create config workspace and save XML as file. Will init git file as version manager. @@ -131,9 +173,13 @@ def create_or_update_config(self, file): # get meta info from XML if self.meta.find(".//{*}identifier"): self.uuid = self.meta.find(".//{*}identifier").text - file_name = self.meta.find("{*}relation").text + + # normalize file name + app_name = self.meta.find("{*}title").text[:20] + decode_file_name = unidecode(app_name) + normalized_file_name = re.sub('[^a-zA-Z0-9 \n\.]', "_", decode_file_name).replace(" ", "_") # save file - self.full_xml_path = path.join(self.workspace, "%s.xml" % file_name) + self.full_xml_path = path.join(self.workspace, "%s.xml" % normalized_file_name) # write file self.write() @@ -159,11 +205,17 @@ def as_data(self): publisher = self.meta.find("{*}publisher").text, url = url, subject = subject, - publish = self.xml.get("publish") + relation = self.meta.find("{*}relation").text if self.meta.find("{*}relation").text else "" ) def as_dict(self): ''' Get config as dict. ''' - return self.as_data().as_dict() \ No newline at end of file + return self.as_data().as_dict() + + def get(self, prop): + dict_data = self.as_data().as_dict() + if prop not in dict_data: + return + return dict_data[prop] \ No newline at end of file diff --git a/srv/python/mviewerstudio_backend/utils/register_utils.py b/srv/python/mviewerstudio_backend/utils/register_utils.py index ab8cdda7..caa9f5fd 100644 --- a/srv/python/mviewerstudio_backend/utils/register_utils.py +++ b/srv/python/mviewerstudio_backend/utils/register_utils.py @@ -131,7 +131,7 @@ def update_from_id(self, id): This allow to change XML manually and update register automatically on service startup. :param id: string uuid use as unique directory name ''' - xml_path = glob.glob("%s/*.xml" % path.join(self.store_directory, id)) + xml_path = glob.glob("%s/*.xml" % path.join(self.store_directory, current_user.organisation, id)) if xml_path: config = from_xml_path(self.app, xml_path[0]) config_dict = config.as_dict()