Compare commits

..

12 Commits

20 changed files with 630 additions and 381 deletions

4
app.py
View File

@ -1,6 +1,6 @@
# Validate configuration before starting the app # Validate configuration before starting the app
from functions.validate_config import validate_config from functions.validate_config import validate_config
from config import item_attributes from config import item_attributes, sql_conf
config_status = validate_config(item_attributes) config_status = validate_config(item_attributes)
if config_status != "Ok": if config_status != "Ok":
@ -23,7 +23,7 @@ from routes.confirm_save import confirm_save_bp
app = Flask(__name__) app = Flask(__name__)
app.secret_key = 'your_secret_key' # Required for flashing messages and session app.secret_key = 'your_secret_key' # Required for flashing messages and session
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql://assetadmin:1234@localhost/asset_test_db' app.config['SQLALCHEMY_DATABASE_URI'] = f'mysql://{sql_conf.SQL_USER}:{sql_conf.SQL_PASSWORD}@{sql_conf.SQL_HOST}/{sql_conf.SQL_DB}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app) db.init_app(app)

View File

@ -1,40 +1,48 @@
from definitions.attribute import Attribute from definitions.attribute import textAttribute, intAttribute, dateAttribute, selectAttribute
item_attributes = { # MySQL information
"assettag": Attribute( class sql_conf:
SQL_USER = "assetadmin"
SQL_PASSWORD = "1234"
SQL_HOST = "localhost"
SQL_DB = "asset_test_db"
SQL_TABLE = "asset_test"
item_attributes = [
textAttribute(
attrib_name="assettag",
display_name="Asset Tag", display_name="Asset Tag",
html_input_type="text",
required=True, required=True,
unique=True, unique=True,
primary=True, primary=True,
regex=r"^[A-Z0-9]+$", # Only uppercase letters and numbers regex=r"^[A-Z0-9]+$", # Only uppercase letters and numbers
default_val=1000000 default_val="1000000"
), ),
"hostname": Attribute( textAttribute(
attrib_name="hostname",
display_name="Host Name", display_name="Host Name",
html_input_type="text",
required=True, required=True,
unique=True, unique=True,
regex=r"^[a-z0-9._-]+$" # Lowercase letters, numbers, dots, underscores, hyphens regex=r"^[a-z0-9._-]+$" # Lowercase letters, numbers, dots, underscores, hyphens
), ),
"warrantyfrom": Attribute( dateAttribute(
attrib_name="warrantyfrom",
display_name="Warranty From", display_name="Warranty From",
html_input_type="date",
default_val="2020-03-09", default_val="2020-03-09",
required=True required=True
), ),
"status": Attribute( selectAttribute(
attrib_name="status",
display_name="Status", display_name="Status",
html_input_type="select",
required=True, required=True,
options=["Active", "Inactive"], # Allowed values options=["Active", "Inactive"], # Allowed values
default_val="Active" default_val="Active"
), ),
"staffnum": Attribute( intAttribute(
attrib_name="staffnum",
display_name="Staff No.", display_name="Staff No.",
html_input_type="number",
required=True, required=True,
min=100000, # 6 digits min_val=100000, # 6 digits
max=99999999, # 8 digits max_val=99999999, # 8 digits
) )
} ]

View File

@ -1,16 +1,205 @@
from datetime import datetime
import re
from typing import List, Optional, Tuple
class Attribute: class Attribute:
def __init__(self, display_name, html_input_type="text", required=False, unique=False, primary=False, regex=None, min=None, max=None, options=None, default_val="", auto_increment=False, index=False, comment="", compareto=None): """Base class for all attribute types."""
self.display_name = display_name # Input label or table column header. def __init__(
self.html_input_type = html_input_type # HTML form input type. Determines MySQL data type. self,
self.required = required # HTML form input "required" attribute and MySQL "Not Null" constraint attrib_name: str,
self.unique = unique # MySQL "unique" constraint display_name: str,
self.primary = primary # MySQL "primary key" constraint html_input_type: str,
self.regex = regex # Regex for value validation required: bool = False,
self.min = min # HTML form input "min" attribute. Sets minimum value. unique: bool = False,
self.max = max # HTML form input "max" attribute. Sets maximum value. primary: bool = False,
self.options = options # List of options for "select" inputs default_val: Optional[str] = None,
self.default_val = default_val # HTML form input "value" attribute. Sets default value. compareto: Optional[List[Tuple[str, str]]] = None,
self.auto_increment = auto_increment # bool: MySQL autoincrement ):
self.index = index # bool: MySQL index self.attrib_name = attrib_name
self.comment = comment # Description text self.display_name = display_name
self.compareto = compareto # Compare to another attribute of the item for validation: ["comparison", "referenceattrib"] self.html_input_type = html_input_type
self.required = required
self.unique = unique
self.primary = primary
self.default_val = default_val
self.compareto = compareto
def validate(self) -> Optional[str]:
"""Validate common attributes. Returns an error message if invalid, otherwise None."""
if not self.attrib_name:
return "Missing attribute name."
if not self.display_name:
return f"Missing display name for attribute '{self.attrib_name}'."
if not self.html_input_type:
return f"Missing input type for attribute '{self.attrib_name}'."
if self.html_input_type not in ["text", "number", "date", "select"]:
return f"Invalid input type for attribute '{self.attrib_name}'."
return None
class textAttribute(Attribute):
"""Class for text attributes."""
def __init__(
self,
attrib_name: str,
display_name: str,
regex: Optional[str] = None,
**kwargs
):
super().__init__(attrib_name, display_name, html_input_type="text", **kwargs)
self.regex = regex
def validate(self) -> Optional[str]:
"""Validate text-specific attributes."""
error = super().validate()
if error:
return error
if self.default_val is not None and self.regex is not None:
if not re.match(self.regex, str(self.default_val)):
return f"default_val does not match the regex pattern for attribute '{self.attrib_name}'."
return None
class intAttribute(Attribute):
"""Class for integer attributes."""
def __init__(
self,
attrib_name: str,
display_name: str,
min_val: Optional[int] = None,
max_val: Optional[int] = None,
**kwargs
):
super().__init__(attrib_name, display_name, html_input_type="number", **kwargs)
self.min_val = min_val
self.max_val = max_val
def validate(self) -> Optional[str]:
"""Validate integer-specific attributes."""
error = super().validate()
if error:
return error
if self.min_val is not None and not isinstance(self.min_val, int):
return f"min_val must be an integer for attribute '{self.attrib_name}'."
if self.max_val is not None and not isinstance(self.max_val, int):
return f"max_val must be an integer for attribute '{self.attrib_name}'."
if self.min_val is not None and self.max_val is not None and self.min_val > self.max_val:
return f"min_val cannot be greater than max_val for attribute '{self.attrib_name}'."
if self.default_val is not None and not isinstance(self.default_val, int):
return f"default_val must be an integer for attribute '{self.attrib_name}'."
if self.default_val is not None:
if self.min_val is not None and self.default_val < self.min_val:
return f"default_val cannot be less than min_val for attribute '{self.attrib_name}'."
if self.max_val is not None and self.default_val > self.max_val:
return f"default_val cannot be greater than max_val for attribute '{self.attrib_name}'."
return None
class floatAttribute(Attribute):
"""Class for float attributes."""
def __init__(
self,
attrib_name: str,
display_name: str,
min_val: Optional[float] = None,
max_val: Optional[float] = None,
**kwargs
):
super().__init__(attrib_name, display_name, html_input_type="number", **kwargs)
self.min_val = min_val
self.max_val = max_val
def validate(self) -> Optional[str]:
"""Validate float-specific attributes."""
error = super().validate()
if error:
return error
if self.min_val is not None and not isinstance(self.min_val, (int, float)):
return f"min_val must be a number for attribute '{self.attrib_name}'."
if self.max_val is not None and not isinstance(self.max_val, (int, float)):
return f"max_val must be a number for attribute '{self.attrib_name}'."
if self.min_val is not None and self.max_val is not None and self.min_val > self.max_val:
return f"min_val cannot be greater than max_val for attribute '{self.attrib_name}'."
if self.default_val is not None and not isinstance(self.default_val, (int, float)):
return f"default_val must be a number for attribute '{self.attrib_name}'."
if self.default_val is not None:
if self.min_val is not None and self.default_val < self.min_val:
return f"default_val cannot be less than min_val for attribute '{self.attrib_name}'."
if self.max_val is not None and self.default_val > self.max_val:
return f"default_val cannot be greater than max_val for attribute '{self.attrib_name}'."
return None
class dateAttribute(Attribute):
"""Class for date attributes."""
def __init__(
self,
attrib_name: str,
display_name: str,
min_val: Optional[str] = None,
max_val: Optional[str] = None,
**kwargs
):
super().__init__(attrib_name, display_name, html_input_type="date", **kwargs)
self.min_val = min_val
self.max_val = max_val
def _is_date(self, value: str) -> bool:
"""Check if a value is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(value, "%Y-%m-%d")
return True
except ValueError:
return False
def validate(self) -> Optional[str]:
"""Validate date-specific attributes."""
error = super().validate()
if error:
return error
if self.min_val is not None and not self._is_date(self.min_val):
return f"min_val must be a valid date (YYYY-MM-DD) for attribute '{self.attrib_name}'."
if self.max_val is not None and not self._is_date(self.max_val):
return f"max_val must be a valid date (YYYY-MM-DD) for attribute '{self.attrib_name}'."
if self.min_val is not None and self.max_val is not None:
min_date = datetime.strptime(self.min_val, "%Y-%m-%d")
max_date = datetime.strptime(self.max_val, "%Y-%m-%d")
if max_date < min_date:
return f"max_val cannot be earlier than min_val for attribute '{self.attrib_name}'."
if self.default_val is not None and not self._is_date(self.default_val):
return f"default_val must be a valid date (YYYY-MM-DD) for attribute '{self.attrib_name}'."
if self.default_val is not None:
default_date = datetime.strptime(self.default_val, "%Y-%m-%d")
if self.min_val is not None:
min_date = datetime.strptime(self.min_val, "%Y-%m-%d")
if default_date < min_date:
return f"default_val cannot be earlier than min_val for attribute '{self.attrib_name}'."
if self.max_val is not None:
max_date = datetime.strptime(self.max_val, "%Y-%m-%d")
if default_date > max_date:
return f"default_val cannot be later than max_val for attribute '{self.attrib_name}'."
return None
class selectAttribute(Attribute):
"""Class for select attributes."""
def __init__(
self,
attrib_name: str,
display_name: str,
options: List[str],
**kwargs
):
super().__init__(attrib_name, display_name, html_input_type="select", **kwargs)
self.options = options
def validate(self) -> Optional[str]:
"""Validate select-specific attributes."""
error = super().validate()
if error:
return error
if not self.options:
return f"options cannot be empty for attribute '{self.attrib_name}'."
if self.default_val is not None and self.default_val not in self.options:
return f"default_val must be one of the options for attribute '{self.attrib_name}'."
return None

View File

@ -1,39 +1,43 @@
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from config import item_attributes # Import the configuration
from sqlalchemy import Enum, Integer, String, Date, Column from sqlalchemy import Enum, Integer, String, Date, Column
from config import item_attributes, sql_conf
from definitions.attribute import textAttribute, intAttribute, floatAttribute, dateAttribute, selectAttribute
# Initialize SQLAlchemy
db = SQLAlchemy() db = SQLAlchemy()
# Dynamically create the Asset model
def create_asset_model(): def create_asset_model():
""" """
Dynamically creates the Asset model based on the configuration in item_attributes. Dynamically creates the Asset model based on the configuration in item_attributes.
""" """
# Define the table name and basic methods
attrs = { attrs = {
'__tablename__': 'asset_test', # Table name '__tablename__': sql_conf.SQL_TABLE, # Table name from config
'__init__': lambda self, **kwargs: self.__dict__.update(kwargs), # Constructor '__init__': lambda self, **kwargs: self.__dict__.update(kwargs), # Constructor
'__repr__': lambda self: f"<Asset {getattr(self, next(attrib for attrib, config in item_attributes.items() if config.primary))}>" # Representation '__repr__': lambda self: f"<Asset {getattr(self, next(attrib.attrib_name for attrib in item_attributes if attrib.primary))}>" # Representation
} }
for attrib, config in item_attributes.items(): # Define columns based on item_attributes
# Determine the column type based on the configuration for attrib in item_attributes:
if config.html_input_type == "text": # Determine the column type
if isinstance(attrib, textAttribute):
column_type = String(50) column_type = String(50)
elif config.html_input_type == "date": elif isinstance(attrib, dateAttribute):
column_type = Date column_type = Date
elif config.html_input_type == "select": elif isinstance(attrib, selectAttribute):
column_type = Enum(*config.options) column_type = Enum(*attrib.options)
elif config.html_input_type == "number": elif isinstance(attrib, (intAttribute, floatAttribute)):
column_type = Integer column_type = Integer if isinstance(attrib, intAttribute) else Float
else:
raise ValueError(f"Unsupported attribute type: {type(attrib)}")
# Define the column with additional properties # Define the column with additional properties
attrs[attrib] = Column( attrs[attrib.attrib_name] = Column(
column_type, column_type,
primary_key=config.primary, primary_key=attrib.primary,
unique=config.unique, unique=attrib.unique,
nullable=not config.required, nullable=not attrib.required,
default=config.default_val, default=attrib.default_val
comment=config.comment
) )
# Create the Asset class dynamically # Create the Asset class dynamically

View File

@ -3,20 +3,34 @@ from io import TextIOWrapper
from config import item_attributes from config import item_attributes
def get_csv_data(file): def get_csv_data(file):
"""Extract and validate CSV data."""
csv_file = TextIOWrapper(file, encoding='utf-8') csv_file = TextIOWrapper(file, encoding='utf-8')
# Check delimiter
sample = csv_file.read(1024)
csv_file.seek(0)
if '|' not in sample:
raise ValueError("CSV file must use '|' as the delimiter.")
reader = csv.DictReader(csv_file, delimiter='|') reader = csv.DictReader(csv_file, delimiter='|')
# Validate CSV headers based on config # Validate CSV headers based on config
required_headers = set(item_attributes.keys()) # Get required headers as per configuration required_headers = {attrib.display_name for attrib in item_attributes} # Use display names
csv_headers = set(reader.fieldnames) # Get headers present in the csv file csv_headers = set(reader.fieldnames) # Get headers present in the CSV file
# Check if the required headers exist # Check if the required headers exist
if not required_headers.issubset(reader.fieldnames): if not required_headers.issubset(csv_headers):
raise ValueError(f"CSV file must include headers: {', '.join(required_headers)}.") raise ValueError(f"CSV file must include headers: {', '.join(required_headers)}.")
# Check for invalid headers # Check for invalid headers
if extra_headers := csv_headers - required_headers: if extra_headers := csv_headers - required_headers:
raise ValueError(f"Unexpected headers found: {', '.join(extra_headers)}") raise ValueError(f"Unexpected headers found: {', '.join(extra_headers)}")
# Map display names to attribute names
display_to_attrib = {attrib.display_name: attrib.attrib_name for attrib in item_attributes}
# Extract data dynamically based on config # Extract data dynamically based on config
return [{attrib: row[attrib] for attrib in item_attributes} for row in reader] return [
{display_to_attrib[header]: row[header] for header in required_headers}
for row in reader
]

View File

@ -1,106 +1,32 @@
import re from typing import List
from datetime import datetime from definitions.attribute import Attribute
def _validate_number(attrib, attrib_name): def validate_config(item_attributes: List[Attribute]) -> str:
"""Validate number-specific attributes.""" """
if attrib.min and not _is_int(attrib.min): Validate the configuration file to ensure all attributes are properly defined.
return False Returns "Ok" if everything is valid, otherwise returns an error message.
if attrib.max and not _is_int(attrib.max): """
return False # Check for duplicate attribute names
if attrib.min and attrib.max and int(attrib.max) < int(attrib.min): attrib_names = [attrib.attrib_name for attrib in item_attributes]
return False if len(attrib_names) != len(set(attrib_names)):
if attrib.default_val and not _is_int(attrib.default_val): return "Duplicate attribute names are not allowed."
return False
if attrib.default_val:
if attrib.min and int(attrib.default_val) < int(attrib.min):
return False
if attrib.max and int(attrib.default_val) > int(attrib.max):
return False
return True
def _validate_date(attrib, attrib_name): # Validate each attribute
"""Validate date-specific attributes.""" for attrib in item_attributes:
if attrib.min and not _is_date(attrib.min): error = attrib.validate()
return False if error:
if attrib.max and not _is_date(attrib.max): return error
return False
if attrib.min and attrib.max and datetime.strptime(attrib.max, "%Y-%m-%d") < datetime.strptime(attrib.min, "%Y-%m-%d"):
return False
if attrib.default_val and not _is_date(attrib.default_val):
return False
if attrib.default_val:
if attrib.min and datetime.strptime(attrib.default_val, "%Y-%m-%d") < datetime.strptime(attrib.min, "%Y-%m-%d"):
return False
if attrib.max and datetime.strptime(attrib.default_val, "%Y-%m-%d") > datetime.strptime(attrib.max, "%Y-%m-%d"):
return False
return True
def _validate_text(attrib, attrib_name): # Validate comparison (if applicable)
"""Validate text-specific attributes."""
if attrib.min or attrib.max or attrib.auto_increment or attrib.options:
return False
if attrib.default_val and attrib.regex and not re.match(attrib.regex, str(attrib.default_val)):
return False
return True
def _validate_select(attrib, attrib_name):
"""Validate select-specific attributes."""
if not attrib.options:
return False
if attrib.default_val and attrib.default_val not in attrib.options:
return False
return True
def _is_int(value):
"""Check if a value is a valid integer."""
try:
int(value)
return True
except (ValueError, TypeError):
return False
def _is_date(value):
"""Check if a value is a valid date in YYYY-MM-DD format."""
try:
datetime.strptime(value, "%Y-%m-%d")
return True
except (ValueError, TypeError):
return False
# Validate the configuration file to ensure all attributes are properly defined.
def validate_config(item_attributes):
for attrib_name, attrib in item_attributes.items():
# Validate display_name and html_input_type
if not attrib.display_name:
return f"Missing display name for attribute '{attrib_name}'."
if not attrib.html_input_type:
return f"Missing input type for attribute '{attrib_name}'."
if attrib.html_input_type not in ["text", "number", "date", "select"]:
return f"Invalid input type for attribute '{attrib_name}'."
# Validate min, max, and default values based on input type
if attrib.html_input_type == "number":
if not _validate_number(attrib, attrib_name):
return f"Invalid number configuration for attribute '{attrib_name}'."
elif attrib.html_input_type == "date":
if not _validate_date(attrib, attrib_name):
return f"Invalid date configuration for attribute '{attrib_name}'."
elif attrib.html_input_type == "text":
if not _validate_text(attrib, attrib_name):
return f"Invalid text configuration for attribute '{attrib_name}'."
elif attrib.html_input_type == "select":
if not _validate_select(attrib, attrib_name):
return f"Invalid select configuration for attribute '{attrib_name}'."
# Validate min and max values
if (attrib.min or attrib.max) and attrib.html_input_type not in ['number', 'date']:
return f"'{attrib_name}' must be of type 'number' or 'date' to have min or max values."
# Validate comparison
if attrib.compareto: if attrib.compareto:
if attrib.compareto[1] not in item_attributes: if not isinstance(attrib.compareto, list) or not all(
return f"Invalid reference attribute '{attrib.compareto[1]}' for comparison in attribute '{attrib_name}'." isinstance(pair, tuple) and len(pair) == 2 for pair in attrib.compareto
if attrib.compareto[0] not in ["lt", "gt", "le", "ge", "eq"]: ):
return f"Invalid comparison operator for attribute '{attrib_name}'. Valid operators are: lt, gt, le, ge, eq." return f"Invalid comparison format for attribute '{attrib.attrib_name}'. Expected a list of tuples."
# Return "Ok" if everything is valid, otherwise return an error message. for cmp, ref_attr in attrib.compareto:
if cmp not in ["lt", "gt", "le", "ge", "eq"]:
return f"Invalid comparison operator '{cmp}' for attribute '{attrib.attrib_name}'."
if ref_attr not in attrib_names:
return f"Invalid reference attribute '{ref_attr}' for comparison in attribute '{attrib.attrib_name}'."
return "Ok" return "Ok"

View File

@ -1,43 +1,103 @@
import re
from datetime import datetime from datetime import datetime
import re
from typing import Dict, Any, Optional
from definitions.attribute import *
from config import item_attributes
# Validate the input value based on the attribute's constraints def _is_int(value: Any) -> bool:
def validate_value(attrib, input_val): """Check if a value is a valid integer (including string representations)."""
# Check if the value is required if isinstance(value, int):
if attrib.required and not input_val: return True
return f"{attrib.display_name} is required." if isinstance(value, str):
# Validate based on input type
if attrib.html_input_type == "number":
try: try:
input_val = int(input_val) int(value)
return True
except ValueError: except ValueError:
return f"{attrib.display_name} must be a valid number." return False
return False
if attrib.min is not None and input_val < attrib.min: def _is_float(value: Any) -> bool:
return f"{attrib.display_name} must be at least {attrib.min}." """Check if a value is a valid float (including string representations)."""
if attrib.max is not None and input_val > attrib.max: if isinstance(value, (int, float)):
return f"{attrib.display_name} must be at most {attrib.max}." return True
elif attrib.html_input_type == "date": if isinstance(value, str):
try: try:
input_val = datetime.strptime(input_val, "%Y-%m-%d") # Validate date format float(value)
if attrib.min is not None and input_val < datetime.strptime(attrib.min, "%Y-%m-%d"): return True
return f"{attrib.display_name} must be on or after {attrib.min}." except ValueError:
if attrib.max is not None and input_val > datetime.strptime(attrib.max, "%Y-%m-%d"): return False
return f"{attrib.display_name} must be on or before {attrib.max}." return False
except:
return f"{attrib.display_name} must be a valid date in YYYY-MM-DD format."
elif attrib.html_input_type == "select":
if input_val not in attrib.options:
return f"{attrib.display_name} must be one of {attrib.options}."
elif attrib.html_input_type == "text":
if attrib.regex and not re.match(attrib.regex, input_val):
return f"{attrib.display_name} is invalid. Allowed pattern: {attrib.regex}."
# Validate comparison def _is_date(value: Any) -> bool:
#if attrib.compareto: """Check if a value is a valid date in YYYY-MM-DD format."""
# compare attrib value if not isinstance(value, str):
#return "" return False
try:
datetime.strptime(value, "%Y-%m-%d")
return True
except ValueError:
return False
# If all checks pass, return "Ok" def validate_values(form_data: Dict[str, Any]) -> Optional[str]:
return "Ok" """
Validate form data against the configuration in item_attributes.
Returns an error message if invalid, otherwise None.
"""
# Check if all required fields are present
for attrib in item_attributes:
if attrib.required and attrib.attrib_name not in form_data:
return f"Missing required field: {attrib.display_name}."
# Check for unexpected fields
submitted_fields = set(form_data.keys())
expected_fields = {attrib.attrib_name for attrib in item_attributes}
if unexpected_fields := submitted_fields - expected_fields:
return f"Unexpected fields submitted: {', '.join(unexpected_fields)}."
# Validate each field
for attrib in item_attributes:
if attrib.attrib_name not in form_data:
continue # Skip optional fields that are not submitted
value = form_data[attrib.attrib_name]
# Validate based on attribute type
if isinstance(attrib, textAttribute):
if attrib.regex is not None and not re.match(attrib.regex, str(value)):
return f"Invalid value for {attrib.display_name}. Must match pattern: {attrib.regex}."
elif isinstance(attrib, intAttribute):
if not _is_int(value):
return f"Invalid value for {attrib.display_name}. Must be an integer."
value_int = int(value) # Convert to integer for range checks
if attrib.min_val is not None and value_int < attrib.min_val:
return f"Invalid value for {attrib.display_name}. Must be at least {attrib.min_val}."
if attrib.max_val is not None and value_int > attrib.max_val:
return f"Invalid value for {attrib.display_name}. Must be at most {attrib.max_val}."
elif isinstance(attrib, floatAttribute):
if not _is_float(value):
return f"Invalid value for {attrib.display_name}. Must be a number."
value_float = float(value) # Convert to float for range checks
if attrib.min_val is not None and value_float < attrib.min_val:
return f"Invalid value for {attrib.display_name}. Must be at least {attrib.min_val}."
if attrib.max_val is not None and value_float > attrib.max_val:
return f"Invalid value for {attrib.display_name}. Must be at most {attrib.max_val}."
elif isinstance(attrib, dateAttribute):
if not _is_date(value):
return f"Invalid value for {attrib.display_name}. Must be a valid date (YYYY-MM-DD)."
if attrib.min_val is not None:
min_date = datetime.strptime(attrib.min_val, "%Y-%m-%d")
if datetime.strptime(value, "%Y-%m-%d") < min_date:
return f"Invalid value for {attrib.display_name}. Must be on or after {attrib.min_val}."
if attrib.max_val is not None:
max_date = datetime.strptime(attrib.max_val, "%Y-%m-%d")
if datetime.strptime(value, "%Y-%m-%d") > max_date:
return f"Invalid value for {attrib.display_name}. Must be on or before {attrib.max_val}."
elif isinstance(attrib, selectAttribute):
if value not in attrib.options:
return f"Invalid value for {attrib.display_name}. Must be one of: {', '.join(attrib.options)}."
return None # No errors

View File

@ -1,7 +1,7 @@
from flask import Blueprint, redirect, session, request, jsonify from flask import Blueprint, redirect, session, request, jsonify
from definitions.models import Asset, db from definitions.models import Asset, db
import json import json
from config import item_attributes # Import the configuration from config import item_attributes
confirm_save_bp = Blueprint('confirm_save', __name__) confirm_save_bp = Blueprint('confirm_save', __name__)
@ -20,7 +20,7 @@ def confirm_save():
for asset_data in edited_assets: for asset_data in edited_assets:
# Dynamically create the Asset object using item_attributes # Dynamically create the Asset object using item_attributes
asset = Asset(**{ asset = Asset(**{
attrib: asset_data[attrib] attrib.attrib_name: asset_data[attrib.attrib_name]
for attrib in item_attributes for attrib in item_attributes
}) })
db.session.add(asset) db.session.add(asset)

View File

@ -1,7 +1,8 @@
from flask import Blueprint, request, render_template, redirect from flask import Blueprint, request, render_template, redirect, flash
from definitions.models import Asset, db from definitions.models import Asset, db
from config import item_attributes from config import item_attributes
from sqlalchemy import exc # Import exc for database exceptions from sqlalchemy import exc # Import exc for database exceptions
from functions.validate_values import validate_values # Form validation
addasset_bp = Blueprint('addasset', __name__) addasset_bp = Blueprint('addasset', __name__)
@ -14,20 +15,35 @@ def create():
# Process submitted form # Process submitted form
if request.method == 'POST': if request.method == 'POST':
# Get data from form # Get data from form
form_data = {attrib: request.form[attrib] for attrib in item_attributes} form_data = {attrib.attrib_name: request.form.get(attrib.attrib_name) for attrib in item_attributes}
try:
primary_attr = next((attrib_name for attrib_name, attrib in item_attributes.items() if attrib.primary), None)
except ValueError:
return render_template('create.html', item_attributes=item_attributes, exc=primary_attr)
# Form validation
error = validate_values(form_data)
if error:
return render_template('create.html', item_attributes=item_attributes, error=error)
# Create the Asset object
item = Asset(**form_data) item = Asset(**form_data)
try: try:
db.session.add(item) db.session.add(item)
db.session.commit() db.session.commit()
except exc.IntegrityError: except exc.IntegrityError as e:
return render_template('create.html', item_attributes=item_attributes, exc='integrity') # Handle duplicate primary key or unique constraint errors
except exc.StatementError: primary_attrib = next((attrib for attrib in item_attributes if attrib.primary), None)
return render_template('create.html', item_attributes=item_attributes, exc='status') if primary_attrib:
error = f"An entry with {primary_attrib.display_name} '{form_data[primary_attrib.attrib_name]}' already exists."
else:
error = "An entry with the same primary key already exists."
return render_template('create.html', item_attributes=item_attributes, error=error)
except exc.StatementError as e:
# Handle other database errors
error = f"Database error: {str(e)}"
return render_template('create.html', item_attributes=item_attributes, error=error)
except Exception as e:
# Handle unexpected errors
error = f"An unexpected error occurred: {str(e)}"
return render_template('create.html', item_attributes=item_attributes, error=error)
# Redirect to /viewall on success
return redirect('/viewall') return redirect('/viewall')

View File

@ -8,7 +8,7 @@ delete_bp = Blueprint('deleteasset', __name__)
def delete(primary_value): def delete(primary_value):
# Identify the primary attribute # Identify the primary attribute
primary_attrib = next( primary_attrib = next(
(attrib for attrib, config in item_attributes.items() if config.primary), (attrib for attrib in item_attributes if attrib.primary),
None None
) )
@ -16,7 +16,7 @@ def delete(primary_value):
return "Primary attribute not defined in configuration." return "Primary attribute not defined in configuration."
# Fetch the item using the primary attribute # Fetch the item using the primary attribute
item = Asset.query.filter_by(**{primary_attrib: primary_value}).first() item = Asset.query.filter_by(**{primary_attrib.attrib_name: primary_value}).first()
if not item: if not item:
abort(404) # Item not found abort(404) # Item not found

View File

@ -1,5 +1,6 @@
from flask import Blueprint, Response from flask import Blueprint, Response
from definitions.models import Asset from definitions.models import Asset
from config import item_attributes
import csv import csv
import io import io
@ -14,12 +15,13 @@ def export_csv():
# Fetch all records from the database # Fetch all records from the database
records = Asset.query.all() records = Asset.query.all()
# Write headers # Write headers (use display names from item_attributes)
writer.writerow([column.name for column in Asset.__mapper__.columns]) headers = [attrib.display_name for attrib in item_attributes]
writer.writerow(headers)
# Write data rows # Write data rows
for record in records: for record in records:
writer.writerow([getattr(record, column.name) for column in Asset.__mapper__.columns]) writer.writerow([getattr(record, attrib.attrib_name) for attrib in item_attributes])
# Prepare the response # Prepare the response
response = Response( response = Response(

View File

@ -2,53 +2,53 @@ from flask import Blueprint, request, render_template, redirect
from definitions.models import Asset, db from definitions.models import Asset, db
from config import item_attributes from config import item_attributes
from sqlalchemy import exc # Import exc for database exceptions from sqlalchemy import exc # Import exc for database exceptions
from functions.validate_values import validate_values # Form validation
update_bp = Blueprint('editasset', __name__) update_bp = Blueprint('editasset', __name__)
@update_bp.route('/update/<string:primary_value>/', methods=['GET', 'POST']) @update_bp.route('/update/<string:primary_value>/', methods=['GET', 'POST'])
def update(primary_value): def update(primary_value):
# Identify the primary attribute # Identify the primary attribute
primary_attrib = next( primary_attrib = next((attrib for attrib in item_attributes if attrib.primary), None)
(attrib for attrib, config in item_attributes.items() if config.primary),
None
)
if not primary_attrib: if not primary_attrib:
return "Primary attribute not defined in configuration." return "Primary attribute not defined in configuration."
# Fetch the item using the primary attribute # Fetch the item using the primary attribute
item = Asset.query.filter_by(**{primary_attrib: primary_value}).first() item = Asset.query.filter_by(**{primary_attrib.attrib_name: primary_value}).first()
if not item: if not item:
return f"{item_attributes[primary_attrib].display_name} '{primary_value}' not found." # Move this to a template return f"{primary_attrib.display_name} '{primary_value}' not found."
if request.method == 'GET': if request.method == 'GET':
# Render the update form with the current item data
return render_template('update.html', item=item, item_attributes=item_attributes) return render_template('update.html', item=item, item_attributes=item_attributes)
if request.method == 'POST': if request.method == 'POST':
# Dynamically get form data using item_attributes # Get data from form
form_data = {attrib: request.form[attrib] for attrib in item_attributes} form_data = {attrib.attrib_name: request.form.get(attrib.attrib_name) for attrib in item_attributes}
# Validate status (if it's an option field) # Form validation
if 'status' in item_attributes and item_attributes['status'].html_input_type == "select": error = validate_values(form_data)
if form_data['status'] not in item_attributes['status'].options: if error:
return render_template('update.html', item=item, exc='status', item_attributes=item_attributes) return render_template('update.html', item=item, item_attributes=item_attributes, error=error)
# Convert staffnum to int (if it's a number field)
if 'staffnum' in item_attributes and item_attributes['staffnum'].html_input_type == "number":
try:
form_data['staffnum'] = int(form_data['staffnum'])
except ValueError:
return render_template('update.html', item=item, exc='staffnum', item_attributes=item_attributes)
# Update the item with the new data # Update the item with the new data
for attrib, value in form_data.items(): for attrib in item_attributes:
setattr(item, attrib, value) setattr(item, attrib.attrib_name, form_data[attrib.attrib_name])
try: try:
db.session.commit() db.session.commit()
except exc.IntegrityError: except exc.IntegrityError:
return render_template('update.html', item=item, exc='integrity', item_attributes=item_attributes) # Handle duplicate primary key or unique constraint errors
except (exc.StatementError, exc.InvalidRequestError): error = f"An entry with {primary_attrib.display_name} '{form_data[primary_attrib.attrib_name]}' already exists."
return render_template('update.html', item=item, exc='status', item_attributes=item_attributes) return render_template('update.html', item=item, item_attributes=item_attributes, error=error)
except exc.StatementError as e:
# Handle other database errors
error = f"Database error: {str(e)}"
return render_template('update.html', item=item, item_attributes=item_attributes, error=error)
except Exception as e:
# Handle unexpected errors
error = f"An unexpected error occurred: {str(e)}"
return render_template('update.html', item=item, item_attributes=item_attributes, error=error)
# Redirect to /viewall on success
return redirect('/viewall') return redirect('/viewall')

View File

@ -1,11 +1,9 @@
from flask import Blueprint, request, render_template, redirect, session from flask import Blueprint, request, render_template, redirect, session, flash
import csv
from io import TextIOWrapper
from definitions.models import Asset, db from definitions.models import Asset, db
from functions.process_csv import get_csv_data # Import the CSV processing function from functions.process_csv import get_csv_data
from config import item_attributes # Import the configuration from functions.validate_values import validate_values
from config import item_attributes
# Create a Blueprint for upload
upload_bp = Blueprint('uploadcsv', __name__) upload_bp = Blueprint('uploadcsv', __name__)
@upload_bp.route('/uploadcsv', methods=['GET', 'POST']) @upload_bp.route('/uploadcsv', methods=['GET', 'POST'])
@ -13,33 +11,46 @@ def upload_file():
if request.method == 'POST': if request.method == 'POST':
# Check if a file was uploaded # Check if a file was uploaded
if 'file' not in request.files: if 'file' not in request.files:
return redirect('uploadcsv') flash("No file uploaded.", "error")
return redirect(request.url)
file = request.files['file'] file = request.files['file']
# Check if the file is a CSV # Check if the file is a CSV
if file.filename == '' or not file.filename.endswith('.csv'): if file.filename == '' or not file.filename.endswith('.csv'):
return redirect('uploadcsv') flash("Please upload a valid CSV file.", "error")
return redirect(request.url)
try: try:
# Extract data from the CSV file # Extract and validate CSV data
csvdata = get_csv_data(file) csvdata = get_csv_data(file)
# Identify the primary attribute # Validate each row of data
primary_attrib = next( errors = []
(attrib for attrib, config in item_attributes.items() if config.primary), for i, row in enumerate(csvdata, start=1):
None error = validate_values(row)
) if error:
errors.append(f"Row {i}: {error}")
if errors:
# Pass errors as a list to the template
return render_template(
'upload.html',
errors=errors
)
# Identify the primary attribute
primary_attrib = next((attrib for attrib in item_attributes if attrib.primary), None)
if not primary_attrib: if not primary_attrib:
return "Primary attribute not defined in configuration." flash("Primary attribute not defined in configuration.", "error")
return redirect(request.url)
# Separate new and existing assets # Separate new and existing assets
new_assets = [] new_assets = []
existing_assets = [] existing_assets = []
for row in csvdata: for row in csvdata:
primary_value = row[primary_attrib] primary_value = row[primary_attrib.attrib_name]
asset_exists = Asset.query.filter_by(**{primary_attrib: primary_value}).first() asset_exists = Asset.query.filter_by(**{primary_attrib.attrib_name: primary_value}).first()
if asset_exists: if asset_exists:
existing_assets.append(row) existing_assets.append(row)
else: else:
@ -48,12 +59,17 @@ def upload_file():
session['assets'] = new_assets # Store new assets in session session['assets'] = new_assets # Store new assets in session
# Redirect to preview page with the CSV data # Redirect to preview page with the CSV data
return render_template('csv_preview.html', new_assets=new_assets, existing=existing_assets, item_attributes=item_attributes) return render_template(
'csv_preview.html',
new_assets=new_assets,
existing=existing_assets,
item_attributes=item_attributes
)
except Exception as e: except Exception as e:
# Handle errors during file processing # Handle errors during file processing
print(f"Error processing CSV file: {str(e)}") flash(f"Error processing CSV file: {str(e)}", "error")
return redirect('uploadcsv') return redirect(request.url)
# Render the upload page for GET requests # Render the upload page for GET requests
return render_template('upload.html') return render_template('upload.html')

View File

@ -6,9 +6,18 @@ viewall_bp = Blueprint('viewall', __name__)
@viewall_bp.route('/viewall/', methods=['GET']) @viewall_bp.route('/viewall/', methods=['GET'])
def view_list(): def view_list():
# Fetch all items from the database
items = Asset.query.all() items = Asset.query.all()
primary_attrib = next(
(attrib for attrib, config in item_attributes.items() if config.primary), # Identify the primary attribute
None primary_attrib = next((attrib for attrib in item_attributes if attrib.primary), None)
if not primary_attrib:
return "Primary attribute not defined in configuration."
# Render the template with items, attributes, and primary attribute
return render_template(
'viewList.html',
items=items,
item_attributes=item_attributes,
primary_attrib=primary_attrib.attrib_name
) )
return render_template('viewList.html', items=items, item_attributes=item_attributes, primary_attrib=primary_attrib)

View File

@ -1,8 +1,10 @@
function collectEditedData(event) { function collectEditedData(event) {
// Extract headers (attribute names) from the table
const headers = [...document.querySelectorAll('.table-new-assets thead th')].map(th => th.dataset.attrib); const headers = [...document.querySelectorAll('.table-new-assets thead th')].map(th => th.dataset.attrib);
const rows = document.querySelectorAll('.table-new-assets tbody tr'); const rows = document.querySelectorAll('.table-new-assets tbody tr');
const assets = []; const assets = [];
// Iterate through rows and collect data
rows.forEach(row => { rows.forEach(row => {
const cells = row.querySelectorAll('td'); const cells = row.querySelectorAll('td');
let asset = {}; let asset = {};
@ -10,12 +12,14 @@ function collectEditedData(event) {
assets.push(asset); assets.push(asset);
}); });
// Create a hidden input field to store the collected data
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'hidden'; input.type = 'hidden';
input.name = 'assets'; input.name = 'assets';
input.value = JSON.stringify(assets); input.value = JSON.stringify(assets);
document.querySelector('form').appendChild(input); document.querySelector('form').appendChild(input);
// Submit the form
event.target.submit(); event.target.submit();
return true; return true;
} }

View File

@ -7,47 +7,44 @@
<body> <body>
<h2 align="center">Add new Item</h2> <h2 align="center">Add new Item</h2>
<form method = "POST"> <!-- Display error message if any -->
{% for attrib, properties in item_attributes.items() -%} {% if error %}
<p>{{ error }}</p>
{% endif %}
<!-- Form for adding a new item -->
<form method="POST">
{% for attrib in item_attributes -%}
<p> <p>
<label for="{{ attrib }}">{{ properties.display_name }}:</label> <label for="{{ attrib.attrib_name }}">{{ attrib.display_name }}:</label>
{%- if properties.html_input_type == "select" %} {% if attrib.html_input_type == "select" %}
<select <!-- Render a dropdown for select attributes -->
id="{{ attrib }}" <select
name="{{ attrib }}" id="{{ attrib.attrib_name }}"
{%- if properties.required %} required {% endif -%} name="{{ attrib.attrib_name }}"
> {% if attrib.required %} required {% endif %}
{% for option in properties.options -%} >
<option value="{{ option }}">{{ option }}</option> {% for option in attrib.options -%}
{% endfor -%} <option value="{{ option }}">{{ option }}</option>
</select> {% endfor %}
</select>
{% else %} {% else %}
<!-- Render an input field for other attributes -->
<input <input
id="{{ attrib }}" id="{{ attrib.attrib_name }}"
type="{{ properties.html_input_type }}" type="{{ attrib.html_input_type }}"
name="{{ attrib }}" name="{{ attrib.attrib_name }}"
{%- if properties.required %} required {% endif -%} {% if attrib.required %} required {% endif %}
{%- if properties.min is not none %} min="{{ properties.min }}" {% endif -%} {% if attrib.html_input_type == "number" %}
{%- if properties.max is not none %} max="{{ properties.max }}" {% endif -%} {% if attrib.min_val is not none %} min="{{ attrib.min_val }}" {% endif %}
{%- if properties.default_val %} value="{{ properties.default_val }}" {% endif -%} {% if attrib.max_val is not none %} max="{{ attrib.max_val }}" {% endif %}
{% endif %}
{% if attrib.default_val is not none %} value="{{ attrib.default_val }}" {% endif %}
/> />
{% endif -%} {% endif %}
</p> </p>
{% endfor %} {% endfor %}
<p><input type = "submit" value = "Submit" /></p> <p><input type="submit" value="Submit" /></p>
</form> </form>
<p align="center">
{%- if exc == 'integrity' -%}
Item with the same assettag already exists
{%- endif -%}
{%- if exc == 'status' -%}
Data input error. Invalid status value
{%- endif -%}
{%- if exc == 'staffnum' -%}
Data input error. Staff number must be an integer
{%- endif -%}
</p>
</body> </body>
</html> </html>

View File

@ -9,50 +9,35 @@
<body> <body>
<h1>CSV Preview</h1> <h1>CSV Preview</h1>
<!-- Display CSV data in a table --> <!-- Render tables for new and existing assets -->
{% if new_assets %} {% for table_name, assets, editable in [
<p>New assets:</p> ('New Assets', new_assets, true),
<table border="1" class="table-new-assets"> ('Existing Assets', existing, false)
<thead> ] %}
<tr> {% if assets %}
{% for attrib, config in item_attributes.items() %} <h2>{{ table_name }}</h2>
<th data-attrib="{{ attrib }}">{{ config.display_name }}</th> <table border="1" class="table-new-assets">
{% endfor %} <thead>
</tr> <tr>
</thead> {% for attrib in item_attributes %}
<tbody> <th data-attrib="{{ attrib.attrib_name }}">{{ attrib.display_name }}</th>
{% for asset in new_assets %} {% endfor %}
<tr> </tr>
{% for attrib, config in item_attributes.items() %} </thead>
<td contenteditable="true">{{ asset[attrib] }}</td> <tbody>
{% for asset in assets %}
<tr>
{% for attrib in item_attributes %}
<td {% if editable %}contenteditable="true"{% endif %}>
{{ asset[attrib.attrib_name] }}
</td>
{% endfor %}
</tr>
{% endfor %} {% endfor %}
</tr> </tbody>
{% endfor %} </table>
</tbody> {% endif %}
</table> {% endfor %}
{% endif %}
{% if existing %}
<p>These assets are already in the database:</p>
<table border="1" class="table-existing-assets">
<thead>
<tr>
{% for attrib, config in item_attributes.items() %}
<th >{{ config.display_name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for asset in existing %}
<tr>
{% for attrib, config in item_attributes.items() %}
<td>{{ asset[attrib] }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<!-- Form button to confirm and save data --> <!-- Form button to confirm and save data -->
{% if new_assets %} {% if new_assets %}

View File

@ -7,46 +7,44 @@
<body> <body>
<h2 align="center">Update Item</h2> <h2 align="center">Update Item</h2>
<form action='' method = "POST"> <!-- Display error message if any -->
{% for attrib, properties in item_attributes.items() -%} {% if error %}
<p>{{ error }}</p>
{% endif %}
<!-- Form for updating an item -->
<form method="POST">
{% for attrib in item_attributes -%}
<p> <p>
<label for="{{ attrib }}">{{ properties.display_name }}:</label> <label for="{{ attrib.attrib_name }}">{{ attrib.display_name }}:</label>
{%- if properties.html_input_type == "select" %} {% if attrib.html_input_type == "select" %}
<select <!-- Render a dropdown for select attributes -->
id="{{ attrib }}" <select
name="{{ attrib }}" id="{{ attrib.attrib_name }}"
{%- if properties.required %} required {% endif -%} name="{{ attrib.attrib_name }}"
> {% if attrib.required %} required {% endif %}
{% for option in properties.options -%} >
<option value="{{ option }}" {% if item[attrib] == option %}selected{% endif %}>{{ option }}</option> {% for option in attrib.options -%}
{% endfor -%} <option value="{{ option }}" {% if item[attrib.attrib_name] == option %}selected{% endif %}>{{ option }}</option>
</select> {% endfor %}
</select>
{% else %} {% else %}
<!-- Render an input field for other attributes -->
<input <input
id="{{ attrib }}" id="{{ attrib.attrib_name }}"
type="{{ properties.html_input_type }}" type="{{ attrib.html_input_type }}"
name="{{ attrib }}" name="{{ attrib.attrib_name }}"
{%- if properties.required %} required {% endif -%} {% if attrib.required %} required {% endif %}
{%- if properties.min is not none %} min="{{ properties.min }}" {% endif -%} {% if attrib.html_input_type == "number" %}
{%- if properties.max is not none %} max="{{ properties.max }}" {% endif -%} {% if attrib.min_val is not none %} min="{{ attrib.min_val }}" {% endif %}
{%- if item[attrib] %} value="{{ item[attrib] }}" {% endif -%} {% if attrib.max_val is not none %} max="{{ attrib.max_val }}" {% endif %}
{% endif %}
{% if item[attrib.attrib_name] %} value="{{ item[attrib.attrib_name] }}" {% endif %}
/> />
{% endif -%} {% endif %}
</p> </p>
{% endfor %} {% endfor %}
<p><input type = "submit" value = "Update" /></p> <p><input type="submit" value="Update" /></p>
</form> </form>
<p align="center">
{%- if exc == 'integrity' -%}
Item with the same assettag already exists
{%- endif -%}
{%- if exc == 'status' -%}
Data input error. Invalid status value
{%- endif -%}
{%- if exc == 'staffnum' -%}
Data input error. Staff number must be an integer
{%- endif -%}
</p>
</body> </body>
</html> </html>

View File

@ -7,15 +7,36 @@
</head> </head>
<body> <body>
<h1>Upload CSV File</h1> <h1>Upload CSV File</h1>
<!-- Display flash messages -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<p style="color: red;">{{ message }}</p>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Display validation errors if any -->
{% if errors %}
<details style="margin-bottom: 20px;">
<summary style="color: red; font-weight: bold;">
Errors found in the CSV file (click to expand):
</summary>
<ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</details>
{% endif %}
<!-- Upload form -->
<form action="/uploadcsv" method="POST" enctype="multipart/form-data"> <form action="/uploadcsv" method="POST" enctype="multipart/form-data">
<label for="file">Choose a CSV file:</label> <label for="file">Choose a CSV file:</label>
<input type="file" id="file" name="file" accept=".csv" required> <input type="file" id="file" name="file" accept=".csv" required>
<br><br> <br><br>
<button type="submit">Upload</button> <button type="submit">Upload</button>
</form> </form>
{% if error %}
<p style="color: red;">{{ error }}</p>
{% endif %}
</body> </body>
</html> </html>

View File

@ -10,8 +10,8 @@
<table border="1" align="center"> <table border="1" align="center">
<tr> <tr>
<!-- Table headers --> <!-- Table headers -->
{% for attrib, config in item_attributes.items() %} {% for attrib in item_attributes %}
<th>{{ config.display_name }}</th> <th>{{ attrib.display_name }}</th>
{% endfor %} {% endfor %}
<th colspan="2">Actions</th> <th colspan="2">Actions</th>
</tr> </tr>
@ -19,8 +19,8 @@
<!-- Table rows --> <!-- Table rows -->
{% for item in items %} {% for item in items %}
<tr> <tr>
{% for attrib, config in item_attributes.items() %} {% for attrib in item_attributes %}
<td>{{ item[attrib] }}</td> <td>{{ item[attrib.attrib_name] }}</td>
{% endfor %} {% endfor %}
<td> <td>
<form action="/update/{{ item[primary_attrib] }}" method="get"> <form action="/update/{{ item[primary_attrib] }}" method="get">