domingo, 3 de noviembre de 2024

SqlAlchemy (I) : Conexión, crer DB, Tablas y columnas. Ejecutar SQL a pelo

Veamos este código python (db.py)

import sqlalchemy as db
import yaml

#1. Read data from the static/tables folder and Create tables
#1.a Read the tables information from file
def getTables(tablesFilePath: str='static/tables/prov.yml')->list:
    with open(tablesFilePath) as f: 
        tableDict=yaml.safe_load(f)	
    return tableDict

#1.b Create Columns of the Table
def getColumn(col: list)->db.Column:
    lst=[]
    aDict={}
    for i, elem in enumerate(col):
        if i==0: lst.append(elem)
        elif i==1: 
            sublst=elem.split('(')
            type =sublst[0]
            lenOrFkey='255'
            if len(sublst)>1:
                lenOrFkey=sublst[1].replace(')','')
            match type.lower():
                case 'int' | 'integer': lst.append(db.Integer())
                case 'float': lst.append(db.Float)
                case 'bool': lst.append(db.Boolean)
                case 'str' | 'string' | 'char' | 'varchar' : lst.append(db.String(int(lenOrFkey))) 
                case 'date': lst.append(db.Date())
                case 'timestamp': lst.append(db.TIMESTAMP())
                case 'json': lst.append(db.JSON())
                case 'foreignkey' : lst.append(db.ForeignKey(lenOrFkey))
    else: aDict |= elem
    column=db.Column(*lst, **aDict)    
    print (str(column))
    return column          
                
#1.c Create the tables from the information                
def getTable(table: dict, metadataObj: db.MetaData)->db.Table:
    return db.Table(table['name'], metadataObj, *[getColumn(col) for col in table['columns']])   


#2. Insert a list of elements in a table
#2.1 Using sqlAlchemist
def insert(table: db.Table | str, listDataDict: list[dict],conn: db.engine.base.Connection=None, commit:bool=False):
    """
    Insert a list of dictionaries in the given table.
    
    Parameters
    ----------
    table: db.Table | str
        Either a db.Table object or the name of the table
    listDataDict: list[dict]
        A list of dictionaries where each dictionary represents a row to be inserted
    conn: db.engine.base.Connection, optional
        The connection to use for the insert. If None, it will use the default engine.
    commit: bool, optional
        If True, will commit the transaction. Defaults to False.
    
    
    Returns
    -------
    A list of tuples of ids of the inserted rows.
    """
    mytable=db.Table('Student', metadataObj, autoload_with=conn.engine) if isinstance(table, str) else table
    query = db.insert(mytable).values(listDataDict).returning(table.c.id)
    Result = conn.execute(query)
    result=Result.fetchall()
    if commit: conn.commit()
    return result

#2.1 Using raw SQL
def insertSQL(table: str, listDataDict: list[dict],conn: db.engine.base.Connection=None, commit:bool=False):
    for aDict in listDataDict:
        fields='(' + ','.join(str(key) for key in aDict.keys()) + ')'
        values=fields.replace("(", "( :"). replace(",",", :") 
        command="INSERT INTO "+ table + fields + "VALUES "+ values 
        conn.execute(db.text(command), **aDict)
        if commit: conn.commit()
        
    
    
#3. List all elements in a table
def listAll(table: db.Table | str, conn: db.engine.base.Connection=None):
    mytable=db.Table('Student', metadataObj, autoload_with=conn.engine) if isinstance(table, str) else table
    query = db.select([mytable])
    result = conn.execute(query)
    return result

#3.1 Execute a command from SQL 
def execSQL(sql: str, conn: db.engine.base.Connection=None, commit: bool=False) ->list:   
    result=conn.execute(db.text(sql))  
    if commit: conn.commit()
    else: return result.fetchall()
    
            
engine=db.create_engine('sqlite:///test.db', echo=True)
conn=engine.connect()

metadataObj = db.MetaData() #extracting the metadata


#4. Construct the DB
'''
tableLst=getTables(tablesFilePath='static/tables/prov.yml')
dbTableLst=[]
for tableInfo in tableLst:
    table=getTable(tableInfo, metadataObj)
    print(repr(table))
    dbTableLst.append(table)
'''
sql="INSERT INTO Student (name, age, tutor) VALUES('Peret',14,1)"
execSQL(sql,conn,True)

sql="CREATE VIEW VWKK AS SELECT * FROM Student a join Teacher b on a.tutor=b.id"
execSQL(sql,conn,True)

result=execSQL('SELECT * FROM VWKK',conn)
#tuples=result.fetchall()
for t in result:
    print(str(t))
    
    
'''
query = db.insert(dbTableLst[0]).values(name='Matthew', subject="Quantum Mechanics").returning(dbTableLst[0].c.id)
Result = conn.execute(query)
print(Result.fetchone().id)   
query = db.insert(dbTableLst[0]).values(name='Motos', subject="Ampliació de matemàtiques").returning(dbTableLst[0].c.id)
Result = conn.execute(query)
print(Result.fetchone().id)   

query = db.insert(dbTableLst[1]).values(name='Matthew', age=18, tutor=1).returning(dbTableLst[1].c.id)
Result = conn.execute(query)
print(Result.fetchone().id)   
query = db.insert(dbTableLst[1]).values(name='Pepet', age=18, tutor=2).returning(dbTableLst[1].c.id)
Result = conn.execute(query)
print(Result.fetchone().id)   
conn.commit()

output = conn.execute(dbTableLst[0].select()).fetchall()
print(output)
output = conn.execute(dbTableLst[1].select()).fetchall()
print(output)

Student= db.Table('Student', metadataObj, autoload_with=engine) #Table object

#Teacher= db.Table('Teacher', metadata, autoload_with=engine) #Table object
#print(repr(metadata.tables['Student','Teacher']))

    
'''    


Esta es la configuración de las tablas en formato yml (static/tables/prov.yml)

 - name: Teacher
   columns:
      - [id, int, primary_key: true, autoincrement: true]
      - [name, str(50), nullable : false]
      - ['subject', str(50), default: Maths]


 - name: Student
   columns:
      - [id, int, primary_key: true, autoincrement: true]
      - [name, str(50), nullable : false]
      - [age, int, default: 15]
      - [tutor, ForeignKey(Teacher.id), nullable: false]









sábado, 2 de noviembre de 2024

Fast HTML (VI). Dejando el tree view en un collapsible panel

1. menu.py


#1. Imports
from fastapi.staticfiles import StaticFiles
from fasthtml import common as fh
import yaml


#2. Defining Menu class
class Menu():
	# Constructor 
	def __init__(self, id, description, action=None, tooltip=None, icon=None):
		self.id = id
		self.description = description
		self.action = action
		self.tooltip=tooltip #if tooltip is not None else description 
		self.icon=icon
		self.parentId=self.parentId=None if self.id is None or self.id<10 else int(str(id)[0:-1])
		self.childrenIds=[]

	# Alternative constructor from a dioctionary using @classmethod
	@classmethod
	def fromDict(cls, d:dict):
		return cls(d['id'], d['description'], d.get('action'), d.get('tooltip'),d.get('icon'))
		
	# Setter for parentId
	def setParendId(self):
		self.parentId=self.parentId=None if self.id is None or self.id<10 else int(str(id)[0:-1])
	
	# Setter for icon 
	def setIcon(self,dictIcons:dict):
		if self.icon is None:
			if len(self.childrenIds)>0:
				self.icon=dictIcons.get('defa')
			else:
				act=self.action[:4]
				self.icon=dictIcons.get(act)
		

#3. Load Resources from conf folder
#3.1  Load icons from conf/menu_icons.yml as a dict. The icons are font-awesome
def getMenuIcons(menuIconsFilePath: str='static/conf/menus.yml')->dict:
	with open(menuIconsFilePath) as f: 
		iconDict=yaml.safe_load(f)	
		return iconDict

#3.2 Load menus from conf/menus.yml as a list of dict
def getMenus(menuFilePath: str='static/conf/menus.yml')->dict:
	with open(menuFilePath) as f: 
		mnuDict=yaml.safe_load(f)	
		mnuLst=[Menu.fromDict(m) for m in mnuDict['menus']]
		return mnuLst
	
#4. Convert the list of menus into a dict whose key is the id
#   and get the children menus and icons
def getMenuDict(menus:list[Menu], dictIcons:dict={}):
	menuDict={}
	for i, m in enumerate(menus):
		m.childrenIds=[elem.id for elem in filter(lambda x:x.parentId==m.id,menus)] # Set childrenIds
		m.setIcon(dictIcons)		
		menuDict[m.id] = m
	return menuDict	
	
#5. Defining the drop down menu
#5. Get the code of a sub menu and its nested (children) menus
def getSubmenuCodeFH(id:int, menuDict:dict[int, Menu]):
	menu=menuDict.get(id)
	paramDict={}
	SpanContent=''
	if len(menu.childrenIds)==0:
		paramDict['hx_post']=f"/action/{menu.action}"
		paramDict['hx_target']="#message"
		paramDict['hx_swap']="innerHTML"
	else:
		SpanContent=fh.I(cls='triang fa fa-caret-right')
		#paramDict['href']="#"
		#paramDict['hx-on:click']="alert('hola'); const e=document.getElementById('child"+str(id)+"');const sty=e.style.display; e.style.display= (sty=='block') ? 'none' :'block'; e.style.position='absolute' " 
		#paramDict['hx-on:click']="const e=document.getElementById('child"+str(id)+"');const sty=e.style.display; e.style.display= (sty=='block') ? 'none' :'block'; e.style.position='absolute' " 
		paramDict['hx-on:click']="showHideMenuChildren("+str(id)+")" 
		
	tttClas='tooltiptext' if menu.id<10 else 'tooltiptextchild'	
	return fh.Li(
		fh.A(
				menu.description,
				fh.Span(
					SpanContent,
					cls='expand'
				),
				fh.Span(
					menu.tooltip,
					cls=tttClas
				) if menu.tooltip is not None else '',	
			**paramDict,
			cls='tooltip' 
		),
		fh.Ul(*[getSubmenuCodeFH(k, menuDict) for k in sorted(menu.childrenIds)], cls='child', id='child'+str(id)),
		cls='parent'
	)
	
# Get the code of the main menu
def getGeneralMenuCodeFH(menuDict:dict[int, Menu]):
	return fh.Body(
    	fh.Div(id='navbar', cls='navbar'),
    	fh.Ul(
			*[getSubmenuCodeFH(k, menuDict) for k in filter(lambda x: x<10,list(menuDict.keys())) ], 
			#id='menu'
		),
		fh.Div('qqqqq',id='message', cls='message'),
		cls='menu'
    )


#5. Get subtree
# Get the code of a sub menu and its nested (children) menus
def getSubTreeViewCodeFH(id:int, menuDict:dict[int, Menu]):
	menu=menuDict.get(id)
	tttClas='tooltiptexttree'	
	paramDict={'cls':'tooltip'}
	tooltip=fh.Span(
		menu.tooltip,
		cls=tttClas
	) if menu.tooltip is not None else ''
	icon=[fh.I(cls=menu.icon),fh.Span(' ',cls='right-spacer')]	
	
	if len(menu.childrenIds)==0:
		paramDict['hx_post']=f"/action/{menu.action}"
		paramDict['hx_target']="#message"
		paramDict['hx_swap']="innerHTML"
		return fh.Li(*icon,menu.description,tooltip,**paramDict)
	else:
		return fh.Li(
			fh.Details(
				fh.Summary(*icon,menu.description,tooltip,**paramDict),
				fh.Ul(*[getSubTreeViewCodeFH(k, menuDict) for k in sorted(menu.childrenIds)], cls='child'),
			)	 
		)

# Get the code of the main menu
def getGeneralTreeViewCodeFH(menuDict:dict[int, Menu]):
	mainParamDict={'id':'main'}
	sidebarParamDict={'id':'mySidebar','cls':'sidebar'}
	btOpenParamDict={'cls':'openbtn inline-block','hx-on:click':"openCloseNav()"}
	aCloseParamDict={'cls':'closebtn','hx-on:click':"openCloseNav()"}	
	ulParamDict={'id':'firsttreeul', 'cls':'tree'}
	divAjuntParamDict={'cls':'ajuntament inline-block'}
	return fh.Body(
		fh.Div(
			fh.Button(fh.I(cls='fa-regular fa-circle-xmark'), **aCloseParamDict),   
			fh.Ul(
				*[getSubTreeViewCodeFH(k, menuDict) for k in filter(lambda x: x<10,list(menuDict.keys())) ], 
				**ulParamDict,
			),
			**sidebarParamDict,
		),
		fh.Div(
			fh.Div(fh.Button('☰ Obri', **btOpenParamDict),fh.H1('Ajuntament de Tavernes', **divAjuntParamDict)),	 
			fh.Div('Here goes the content of the main window!!!'),
			**mainParamDict,
   		),	
		fh.Div('Ací van els missatges ....',id='message', cls='message')
    )



dictIcons=getMenuIcons('static/conf/menu_icons.yml')
menus=getMenus('static/conf/menus.yml')
menuDict=getMenuDict(menus,dictIcons=dictIcons)

#6. Defining Headers (Styles, Scripts, etc)
myHeaders = [
	fh.Meta(name='viewport', content='width=device-width, initial-scale=1'),
	#fh.Link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css'),
	fh.Link(rel='stylesheet', href='https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css'),
	fh.Link(rel='stylesheet', href='../static/css/menu.css'),
 	fh.Link(rel='stylesheet', href='../static/css/treeview.css'),
	fh.Link(rel='stylesheet', href='../static/css/sidebar.css'),
	fh.Script(src='../static/js/menu.js'),
	fh.Script(src='../static/js/sidebar.js')
]

#7. Creating FastAPI App
app = fh.FastHTML(hdrs=myHeaders)

#8. Mounting the static folder for accessing the menu.css
app.mount("/static", StaticFiles(directory="static"), name="static")
fh.serve()

#9. Defining the routes
@app.get("/")
def geto1():
	return getGeneralMenuCodeFH(menuDict=menuDict) 

@app.get("/tree")
def geto2():
	return getGeneralTreeViewCodeFH(menuDict=menuDict)

#10. Menu Actions executed in a
@app.post("/action/{actEdu}")
def postAction(actEdu:str):
	return f"Action received: {actEdu}"


2.  css

2.1 static/css/menu.css


body {font-family: Arial, Helvetica, sans-serif;}
.navbar {overflow: hidden;background-color: black;position:fixed;top: 0;height:46px; width: 100%; z-index:-1}
#menu {padding-left:10px; }
.menu {padding-left:10px; }

.parent {display: block;position: relative;float: left;line-height: 30px;background-color: black;border-right:#CCC 1px solid;}	
.parent a{margin: 10px;color: #FFFFFF;text-decoration: none;}
.parent:hover > ul {display:block;position:absolute;}
/*.parent li:hover {background-color: #F0F0F0;}*/
.menu .parent li:hover {background-color: Gainsboro;}

/*
.child {display: none;padding-top: 10px;box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);} FALLA 
.menu .child {display: none;padding-top: 10px;box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);width:300px;} SI
.menu .child {display: none;padding-top: 10px;box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);width:auto;} FALLA
*/
.menu .child {display: none;padding-top: 10px;box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.2);width:16em;z-index:2}

.menu .child li {background-color: #f9f9f9;line-height: 30px;border-right:#CCC 1px solid; width:100%;}
.menu .child li a{color: #000000;}
.menu .child .child{padding-top: 0px;}

/*ul{list-style: none;margin: 0;padding: 0px; min-width:10em;}*/
.menu ul{list-style: none;margin: 0;padding: 0px; min-width:10em;}
/*ul ul ul{left: 100%;top: 0;margin-left:1px;}*/
.menu ul ul ul{left: 100%;top: 0;margin-left:1px;}

/*li:hover {background-color: red;}*/
.menu li:hover {background-color: red;}

.menu .expand{float:right;margin-right:5px;}
.menu .triang{margin-top:6px;}
.message {position: fixed; bottom:50px;color: green;background-color: white;border-right:#CCC 1px solid;}	

/*.tooltip {}*/
.menu .tooltip .tooltiptext {	
	visibility: hidden;	width: 150px; font-size:85%;
	color: black; background-color:gold;
	text-align: center;	border-radius: 6px;	padding: 5px 0;
	position: absolute;	z-index: 1;	top: -5px;	left: 105%;
}
.menu .tooltip:hover .tooltiptext {visibility: visible;}


.menu .tooltip .tooltiptextchild {	
	visibility: hidden;	width: 150px; font-size:85%;
	color: black; background-color:gold;
	text-align: center;	border-radius: 6px;	padding: 5px 0;
	position: absolute;	z-index: 1;	top: -5px;	left: 105%;
}
.menu .tooltip:hover .tooltiptextchild {visibility: visible;}	


2.2 static/css/treeview.css


.tree {
    --spacing: 1.5rem;
    --radius: 10px;
    line-height:var(--spacing); /*EDU*/
    /*cursor:default;*/
    cursor: pointer;
  }
  
  .tree li {
    display: block;
    position: relative;
    padding-left: calc(2 * var(--spacing) - var(--radius) - 2px);
  }

  
  
  .tree ul {
    margin-left: calc(var(--radius) - var(--spacing));
    padding-left: 0;
  }

  #firsttreeul {
    margin-left: 0px;
    padding-left: 10px;
    
  }
  
  .tree ul li {
    border-left: 2px solid #ddd;
  }
  
  .tree ul li:last-child {
    border-color: transparent;
  }
  
  .tree ul li::before {
    content: '';
    display: block;
    position: absolute;
    top: calc(var(--spacing) / -2);
    left: -2px;
    width: calc(var(--spacing) + 2px);
    height: calc(var(--spacing) + 1px);
    border: solid #ddd;
    border-width: 0 0 2px 2px;
  }
  
  .tree summary {
    display: block;
    /*cursor: pointer;*/
    cursor: default;
  }
  
  .tree summary::marker,
  .tree summary::-webkit-details-marker {
    display: none;
  }
  
  .tree summary:focus {
    outline: none;
  }
  
  .tree summary:focus-visible {
    outline: 1px dotted #000;
  }
  
  .tree li::after,
  .tree summary::before {
    content: '';
    display: block;
    position: absolute;
    top: calc(var(--spacing) / 2 - var(--radius));
    left: calc(var(--spacing) - var(--radius) - 1px);
    width: calc(2 * var(--radius));
    height: calc(2 * var(--radius));
    border-radius: 50%;
    background: #ddd;
  }
  
  .tree summary::before {
    z-index: 1;
    background: #696 url('../svg/expand-collapse.svg') 0 0;
  }
  
  .tree details[open] > summary::before {
    background-position: calc(-2 * var(--radius)) 0;
  }

  .right-space {
    padding-right: 1em;
  }

  .tree .tooltip .tooltiptexttree {	
    /*visibility: hidden;*/
    display:none;

    width: 150px; font-size:85%;
    color: black; background-color:gold;
    
    text-align: center;	border-radius: 6px;	padding: 5px 0;
    /*position: absolute;*/	
    /*top: -5px;	left: 105%;*/
    margin-left: 1em;
    padding: 0px 0.5em 0px 0.5em;
    width:fit-content;
    
    z-index: 1;	
  }

  .tree .tooltip:hover  {color: green; font-weight: bold;}	
  /*.tree .tooltip:hover  .tooltiptexttree {visibility: visible;}	*/
  .tree .tooltip:hover  .tooltiptexttree {display: inline-block;}	


2.3 static/css/sidebar.css


.sidebar {
    height: 100%;
    width: 0;
    position: fixed;
    z-index: 1;
    top: 0;
    left: 0;
    background-color: #f0f0f0;
    overflow-x: hidden;
    transition: 0.5s;
    padding-top: 15px;
  }
  
  .sidebar .closebtn {
    position: absolute;
    top: 0;
    right: 25px;
    background-color: #04AA6D;
    color:white;
    font-size: 25px;
    margin-left: 50px;
    padding-left: 5px;
    padding-right: 5px;
    border: none;
    border-radius: 8px
  }
  .sidebar .closebtn:hover {
    background-color: #026e47;
    font-weight: bold;
  }  

.openbtn {
  font-size: 20px;
  cursor: pointer;
  background-color: #04AA6D;
  color: white;
  padding: 10px 15px;
  border: none;
  border-radius: 12px
}

.openbtn:hover {
  background-color:#026e47;
  
}

#main {
  transition: margin-left .5s;
  padding-left: 16px;
  
}

.inline-block {
    display: inline-block;
}

.ajuntament {
    padding-left: 1em;
}

/* On smaller screens, where height is less than 450px, change the style of the sidenav (less padding and a smaller font size) */
@media screen and (max-height: 450px) {
  .sidebar {padding-top: 15px;}
  .sidebar a {font-size: 18px;}
}


3. js

3.1 static/js/menu.js


showHideMenuChildren = function(id) {
    let childId='child'+id.toString()
    let e=document.getElementById(childId);
    let sty=e.style.display;
    
    // Hide the children of other menus
    let other = document.querySelectorAll("[id^='child']");
    for (let i = 0; i < other.length; i++) {
        //if (!other[i].id.startsWith(childId) && !childId.startsWith(other[i].id)) {
        //if (!other[i].id.startsWith(childId) ) {
            if( childId.startsWith(other[i].id)) {
                //alert(other[i].id + '-' + other[i].style.display + '-'+other[i].style.position)
                other[i].style.display= (sty=='block') ? null : 'block';
                other[i].style.position=(sty=='block') ? null : 'absolute'
                
            } else {
                other[i].style.display = null;
                other[i].style.position = null;
            }
            console.log(other[i].id + '-' + other[i].style.display + '-'+other[i].style.position)    
        //}
    }
    console.log("-----------------------------------------------------------");
    //Change the state of the elements  
    //e.style.display= (sty=='block') ? null : 'block';
    //e.style.position=(sty=='block') ? null : 'absolute'
}


3.2 static/js/sidebar.js


{
    let isOpen = false ;
    const panelWidth = "400px";
    
    function openCloseNav() {
      let myWidth = isOpen ? "0" : panelWidth;
      document.getElementById("mySidebar").style.width = myWidth;
      document.getElementById("main").style.marginLeft = myWidth;
      isOpen=!isOpen
    }
}



4. datos de configuracion yml


4.1 static/conf/menus.yml


menus:
  - { id: 1    , description: 'Secretaria'                    , tooltip: 'Secretaria guai!' , icon: 'fa-solid fa-skull-crossbones'}
  - { id: 11   , description: "Llibre d'actes de plenari bonico"         , action: null               }
  - { id: 111  , description: "1. Extreure els documents"     , action: 'acti-1-extr-docs'  }
  - { id: 112  , description: "2. Signar documents Alcaldia"  , action: 'acti-1-sign-alc'   }
  - { id: 113  , description: "3. Signar documents Secretaria", action: 'acti-1-sign-secr'  }
  - { id: 2    , description: 'Personal'                                }
  - { id: 21   , description: 'Control Presència'             , action: 'acti-2-contr-pres' }
  - { id: 3    , description: 'Gestió Tributària'                    }
  - { id: 31   , description: 'Menu 31'   , action: 'rept-3-1'  }
  - { id: 32   , description: 'Menu 32'   , action: 'view-3-2'  }
  - { id: 321  , description: 'Menu 321'  , action: 'acti-3-21' , icon: 'fa-solid fa-skull-crossbones'}
  - { id: 322  , description: 'Menu 322'   }
  - { id: 323  , description: 'Menu 323'   }
  - { id: 324  , description: 'Menu 324'  , action: 'acti-3-24'}
  - { id: 3221 , description: 'Menu 3221' , action: 'acti-3-221'}
  - { id: 3222 , description: 'Menu 3222' , action: 'acti-3-222'}
  - { id: 3223 , description: 'Menu 3223' , action: 'acti-3-223'}
  - { id: 3224 , description: 'Menu 3224' , action: 'acti-3-224'}
  - { id: 3225 , description: 'Menu 3225' , action: 'acti-3-225'}
  - { id: 3226 , description: 'Menu 3221' , action: 'acti-3-221'}
  - { id: 3227 , description: 'Menu 3222' , action: 'acti-3-222'}
  - { id: 3228 , description: 'Menu 3223' , action: 'acti-3-223'}
  - { id: 3229 , description: 'Menu 3224' , action: 'acti-3-224'}
  - { id: 3231 , description: 'Menu 3221' , action: 'acti-3-221'}
  - { id: 3232 , description: 'Menu 3222' , action: 'acti-3-222'}
  - { id: 3233 , description: 'Menu 3223' , action: 'acti-3-223'}
  - { id: 3234 , description: 'Menu 3224' , action: 'acti-3-224'}
  - { id: 3235 , description: 'Menu 3225' , action: 'acti-3-225'}
  - { id: 3236 , description: 'Menu 3221' , action: 'acti-3-221'}
  - { id: 3237 , description: 'Menu 3222' , action: 'acti-3-222'}
  - { id: 3238 , description: 'Menu 3223' , action: 'acti-3-223'}
  - { id: 3239 , description: 'Menu 3224' , action: 'acti-3-224'}
  - { id: 4    , description: 'Intervenció' , action: 'acti-4-intervencio'}


4.2 static/conf/menu_icons.yml


defa: 'fa-solid fa-folder'
view: 'fa-solid fa-table-cells'
rept: 'fa-solid fa-rectangle-list'
acti: 'fa-solid fa-gears'


Y sale: