├── .dockerignore ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── __init__.py ├── config.py ├── models │ ├── __init__.py │ ├── asset.py │ ├── audit.py │ ├── group.py │ ├── notification.py │ ├── setting.py │ ├── tag.py │ └── user.py ├── routes │ ├── .smbdeleteAAA7283c0d │ ├── __init__.py │ ├── admin.py │ ├── api.py │ ├── asset.py │ ├── auth.py │ ├── backup.py │ ├── notification.py │ ├── oidc_auth.py │ └── static_pages.py ├── static │ ├── css │ │ └── style.css │ ├── image │ │ └── AL_logo.png │ ├── js │ │ └── main.js │ └── uploads │ │ └── .gitkeep ├── templates │ ├── admin │ │ ├── audit_logs.html │ │ ├── backup.html │ │ ├── dashboard.html │ │ ├── groups │ │ │ ├── create.html │ │ │ ├── edit.html │ │ │ └── list.html │ │ ├── settings.html │ │ └── users │ │ │ ├── create.html │ │ │ ├── edit.html │ │ │ └── list.html │ ├── assets │ │ ├── create.html │ │ ├── dashboard.html │ │ ├── detail.html │ │ ├── edit.html │ │ ├── import.html │ │ ├── list.html │ │ └── tags.html │ ├── auth │ │ ├── 2fa_admin_overview.html │ │ ├── login.html │ │ ├── login_with_sso.html │ │ ├── profile.html │ │ ├── register.html │ │ ├── reset_password.html │ │ ├── reset_password_request.html │ │ └── setup_2fa.html │ ├── email │ │ ├── reset_password.html │ │ ├── reset_password.txt │ │ ├── warranty_alert.html │ │ └── warranty_alert.txt │ ├── emails │ │ ├── reset_password.html │ │ ├── reset_password.txt │ │ ├── warranty_alert.html │ │ └── warranty_alert.txt │ ├── errors │ │ ├── 404.html │ │ └── 500.html │ ├── layout.html │ ├── notification │ │ ├── acknowledge.html │ │ ├── acknowledge_renewal.html │ │ ├── admin_settings.html │ │ ├── debug_scheduler.html │ │ ├── preferences.html │ │ └── renewal_form.html │ ├── partials │ │ ├── alerts.html │ │ ├── navbar.html │ │ └── sidebar.html │ └── static_pages │ │ └── about.html └── utils │ ├── __init__.py │ ├── audit.py │ ├── backup_scheduler.py │ ├── config_service.py │ ├── email.py │ ├── oidc.py │ ├── scheduler.py │ ├── time_utils.py │ └── timezone_util.py ├── backups └── default_Backup_directory ├── docker-compose.yml ├── entrypoint.sh ├── instance ├── .gitkeep └── default_database_directory ├── logs └── .gitkeep ├── requirements.txt ├── run.py ├── screenshots ├── Admin dashboard.png ├── Asset Dashboard.png ├── Asset List.png ├── Audit Logs.png ├── Database Backup Settings.png ├── Email Notification.png ├── Email Reponse2.png ├── Email Response1.png ├── Email Response3.png ├── Email Response4.png ├── Email SMTP Settings.png ├── Group Management.png ├── Notification Settings 1.png ├── Notification Settings 2.png ├── SSO Settings.png ├── System Settings.png ├── Tags Management.png └── User Management.png └── uploads └── .gitkeep /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | README.md 4 | .pytest_cache 5 | .coverage 6 | .env* 7 | node_modules 8 | *.log 9 | __pycache__ 10 | *.pyc 11 | .DS_Store 12 | .vscode 13 | .idea 14 | docker-compose*.yml 15 | Dockerfile 16 | .dockerignore 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Python 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | *.so 6 | .Python 7 | build/ 8 | develop-eggs/ 9 | dist/ 10 | downloads/ 11 | eggs/ 12 | .eggs/ 13 | lib/ 14 | lib64/ 15 | parts/ 16 | sdist/ 17 | var/ 18 | wheels/ 19 | *.egg-info/ 20 | .installed.cfg 21 | *.egg 22 | 23 | screenshots 24 | screenshots/ 25 | asset-lookup/screenshots/ 26 | 27 | # Flask 28 | 29 | .webassets-cache 30 | 31 | # Environment variables 32 | .env 33 | .env.local 34 | .env.*.local 35 | .venv 36 | env/ 37 | venv/ 38 | ENV/ 39 | env.bak/ 40 | venv.bak/ 41 | 42 | # Database 43 | *.db 44 | *.sqlite3 45 | 46 | # Logs 47 | *.log 48 | logs/*.log 49 | 50 | # Uploads (keep directory structure but not files) 51 | uploads/* 52 | !uploads/.gitkeep 53 | instance/* 54 | !instance/.gitkeep 55 | 56 | # IDE 57 | .vscode/ 58 | .idea/ 59 | *.swp 60 | *.swo 61 | *~ 62 | 63 | # OS 64 | .DS_Store 65 | .DS_Store? 66 | ._* 67 | .Spotlight-V100 68 | .Trashes 69 | ehthumbs.db 70 | Thumbs.db 71 | 72 | 73 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-slim 2 | 3 | WORKDIR /app 4 | 5 | # Install system dependencies 6 | RUN apt-get update && apt-get install -y --no-install-recommends \ 7 | gcc \ 8 | libpq-dev \ 9 | curl \ 10 | && apt-get clean \ 11 | && rm -rf /var/lib/apt/lists/* 12 | 13 | # Copy requirements first to leverage Docker cache 14 | COPY requirements.txt . 15 | RUN pip install --no-cache-dir -r requirements.txt 16 | 17 | # Copy application code 18 | COPY . . 19 | 20 | # Set environment variables 21 | ENV PYTHONDONTWRITEBYTECODE=1 22 | ENV PYTHONUNBUFFERED=1 23 | ENV FLASK_APP=run.py 24 | 25 | # Make entrypoint script executable 26 | RUN chmod +x /app/entrypoint.sh 27 | 28 | # Expose the port the app runs on 29 | EXPOSE 5000 30 | 31 | # Set entrypoint 32 | ENTRYPOINT ["/app/entrypoint.sh"] 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Custom Software License - Non-Commercial Use Only 2 | 3 | Copyright (c) 2025 - Toks Hernandez 4 | 5 | Permission is granted to individuals and organizations to use this software under the following conditions: 6 | 7 | 1. Non-Commercial Use Only 8 | This software is provided for personal, educational, or non-commercial use only. Use in any commercial context — including but not limited to SaaS platforms or monetized products — is prohibited without a separate commercial license. 9 | Permitted Uses 10 | * Personal use 11 | * Educational use 12 | * Research and development 13 | * Non-profit organizations 14 | * Government use 15 | * Internal business use (non-commercial) 16 | 17 | 18 | 2. No Modification or Derivative Works 19 | You may not modify, adapt, reverse engineer, decompile, or create derivative works based on this software. It must be used as-is. 20 | 21 | 22 | 3. No Redistribution 23 | You may not redistribute this software or host it in public repositories, package managers, or other distribution channels - unless given permission by the developer - contact thokzz.github@gmail.com. 24 | 25 | 26 | 4. Copyright and Attribution 27 | You must retain this license notice and all copyright notices in any copies of the software. 28 | 29 | 30 | 5. Commercial Licensing 31 | Commercial use requires a separate license. Please contact thokzz.github@gmail.com to inquire about commercial licensing. 32 | 33 | 34 | 6. Disclaimer 35 | This software is provided "as is", without warranty of any kind, express or implied. The author(s) shall not be liable for any damages arising from the use of this software. 36 | -------------------------------------------------------------------------------- /app/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import secrets 3 | 4 | class Config: 5 | # Basic configuration 6 | SECRET_KEY = os.environ.get('SECRET_KEY') or secrets.token_hex(32) 7 | JWT_SECRET_KEY = os.environ.get('JWT_SECRET_KEY') or secrets.token_hex(32) 8 | STATIC_FOLDER = 'static' 9 | 10 | # External URL settings 11 | SERVER_HOST = os.environ.get('SERVER_HOST', '0.0.0.0') 12 | SERVER_PORT = int(os.environ.get('SERVER_PORT', 5000)) 13 | EXTERNAL_URL = os.environ.get('EXTERNAL_URL') 14 | 15 | # Database configuration - FIXED: Use absolute path 16 | SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:////app/instance/asset_lookup.db' 17 | SQLALCHEMY_TRACK_MODIFICATIONS = False 18 | 19 | # File upload settings 20 | UPLOAD_FOLDER = 'static/uploads' 21 | ALLOWED_EXTENSIONS = {'pdf', 'doc', 'docx', 'xls', 'xlsx', 'txt', 'png', 'jpg', 'jpeg', 'gif', 'zip', 'rar'} 22 | MAX_CONTENT_LENGTH = 10 * 1024 * 1024 # 10MB 23 | 24 | # Email settings 25 | MAIL_SERVER = os.environ.get('MAIL_SERVER') or '' 26 | MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587) 27 | MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'True') != 'False' 28 | MAIL_USERNAME = os.environ.get('MAIL_USERNAME') or '' 29 | MAIL_PASSWORD = os.environ.get('MAIL_PASSWORD') or '' 30 | MAIL_DEFAULT_SENDER = os.environ.get('MAIL_DEFAULT_SENDER') or 'noreply@assetlookup.com' 31 | 32 | # Application settings 33 | ALLOW_REGISTRATION = os.environ.get('ALLOW_REGISTRATION', 'True') != 'False' 34 | APP_TIMEZONE = os.environ.get('APP_TIMEZONE') or 'UTC' 35 | 36 | # 2FA settings 37 | TWO_FACTOR_ENABLED = os.environ.get('TWO_FACTOR_ENABLED', 'False') == 'True' 38 | 39 | # Frontend URL for CORS 40 | FRONTEND_URL = os.environ.get('FRONTEND_URL') or '*' 41 | 42 | # Ensure we don't have redirection issues 43 | PREFERRED_URL_SCHEME = os.environ.get('PREFERRED_URL_SCHEME', 'http') 44 | APPLICATION_ROOT = '/' 45 | SESSION_COOKIE_SECURE = False 46 | SESSION_COOKIE_HTTPONLY = True 47 | SESSION_COOKIE_SAMESITE = 'Lax' 48 | SERVER_NAME = None # Important: Remove server name to prevent redirection issues -------------------------------------------------------------------------------- /app/models/__init__.py: -------------------------------------------------------------------------------- 1 | # app/models/__init__.py 2 | # This file makes the directory a Python package 3 | # It's important to import the models in the right order 4 | # to avoid circular import errors 5 | 6 | # First, import models that don't depend on other models 7 | from app.models.setting import Setting 8 | from app.models.tag import Tag 9 | from app.models.audit import AuditLog 10 | from app.models.group import Group, Permission 11 | from app.models.notification import NotificationSetting, NotificationLog 12 | # Then import models that depend on others 13 | from app.models.user import User 14 | 15 | # Finally, import models that depend on User 16 | from app.models.asset import Asset, AssetFile# This file makes the directory a Python package 17 | -------------------------------------------------------------------------------- /app/models/asset.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | import uuid 3 | from datetime import timedelta 4 | from dateutil.relativedelta import relativedelta 5 | 6 | # Import the time utilities 7 | from app.utils.time_utils import utcnow, today, utc_to_local, local_to_utc 8 | 9 | # Asset-Tag association table 10 | asset_tag = db.Table('asset_tag', 11 | db.Column('asset_id', db.String(36), db.ForeignKey('assets.id')), 12 | db.Column('tag_id', db.String(36), db.ForeignKey('tags.id')) 13 | ) 14 | 15 | class Asset(db.Model): 16 | __tablename__ = 'assets' 17 | 18 | id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) 19 | product_name = db.Column(db.String(255), nullable=False) 20 | product_model = db.Column(db.String(255)) 21 | internal_asset_name = db.Column(db.String(255)) 22 | serial_number = db.Column(db.String(255)) 23 | purchase_date = db.Column(db.Date, nullable=False) 24 | price = db.Column(db.Float) 25 | currency_symbol = db.Column(db.String(5), default='$') 26 | warranty_duration = db.Column(db.Integer, nullable=False) # In months 27 | location = db.Column(db.String(255)) 28 | vendor_company = db.Column(db.String(255)) 29 | vendor_email = db.Column(db.String(255)) 30 | notes = db.Column(db.Text) 31 | disposal_date = db.Column(db.Date) 32 | alert_period = db.Column(db.Integer, default=30) # Days before expiry to alert 33 | # Use the utcnow function from time_utils 34 | created_at = db.Column(db.DateTime, default=utcnow) 35 | updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow) 36 | notifications_enabled = db.Column(db.Boolean, default=True) 37 | notification_emails = db.Column(db.Text) 38 | 39 | # Add an actual column for warranty_expiry_date 40 | warranty_expiry_date = db.Column(db.Date) 41 | 42 | # User assignment 43 | assigned_user_id = db.Column(db.String(36), db.ForeignKey('users.id')) 44 | user_email = db.Column(db.String(255)) 45 | 46 | # Define the relationship here only, not in User model 47 | assigned_user = db.relationship('User', foreign_keys=[assigned_user_id]) 48 | 49 | # Relationships 50 | tags = db.relationship('Tag', secondary=asset_tag, backref=db.backref('assets', lazy='dynamic')) 51 | assigned_groups = db.relationship('Group', secondary='asset_group', backref=db.backref('assigned_assets', lazy='dynamic')) 52 | files = db.relationship('AssetFile', backref='asset', cascade='all, delete-orphan') 53 | 54 | def __init__(self, *args, **kwargs): 55 | super(Asset, self).__init__(*args, **kwargs) 56 | # Calculate warranty_expiry_date when the object is created 57 | self.update_warranty_expiry_date() 58 | 59 | def update_warranty_expiry_date(self): 60 | """Update warranty_expiry_date based on purchase_date and warranty_duration""" 61 | if self.purchase_date and self.warranty_duration: 62 | # More accurate calculation for warranty expiry 63 | # This approach handles month transitions properly 64 | self.warranty_expiry_date = self.purchase_date + relativedelta(months=self.warranty_duration) 65 | else: 66 | self.warranty_expiry_date = None 67 | 68 | def get_notification_emails_list(self): 69 | """Get notification emails as a list""" 70 | if not self.notification_emails: 71 | return [] 72 | return [email.strip() for email in self.notification_emails.split(',') if email.strip()] 73 | 74 | def set_notification_emails_list(self, email_list): 75 | """Set notification emails from a list""" 76 | if email_list: 77 | # Remove duplicates and empty strings 78 | clean_emails = list(set([email.strip() for email in email_list if email.strip()])) 79 | self.notification_emails = ','.join(clean_emails) 80 | else: 81 | self.notification_emails = None 82 | 83 | @property 84 | def is_expired(self): 85 | """Check if warranty has expired""" 86 | if self.warranty_expiry_date: 87 | # Use today() from time_utils instead of datetime.utcnow().date() 88 | return today() > self.warranty_expiry_date 89 | return False 90 | 91 | @property 92 | def is_expiring_soon(self): 93 | """Check if warranty is expiring soon based on alert_period""" 94 | if self.warranty_expiry_date: 95 | # Use today() from time_utils 96 | days_remaining = (self.warranty_expiry_date - today()).days 97 | return 0 < days_remaining <= self.alert_period 98 | return False 99 | 100 | @property 101 | def days_remaining(self): 102 | if self.warranty_expiry_date: 103 | # Use today() from time_utils 104 | delta = self.warranty_expiry_date - today() 105 | return delta.days 106 | return 0 107 | 108 | @property 109 | def status(self): 110 | """Calculate the asset's status based on warranty_expiry_date""" 111 | # Use today() from time_utils 112 | current_date = today() 113 | if not self.warranty_expiry_date: 114 | return 'Unknown' 115 | if self.warranty_expiry_date <= current_date: 116 | return 'Expired' 117 | # Use alert_period instead of hardcoded 30 days for consistency 118 | if self.warranty_expiry_date <= current_date + timedelta(days=self.alert_period): 119 | return 'Expiring Soon' 120 | # Otherwise, it's active 121 | return 'Active' 122 | 123 | 124 | class AssetFile(db.Model): 125 | __tablename__ = 'asset_files' 126 | 127 | id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) 128 | asset_id = db.Column(db.String(36), db.ForeignKey('assets.id'), nullable=False) 129 | filename = db.Column(db.String(255), nullable=False) 130 | file_path = db.Column(db.String(255), nullable=False) 131 | file_type = db.Column(db.String(50)) 132 | file_size = db.Column(db.Integer) # In bytes 133 | # Use the utcnow function from time_utils 134 | uploaded_at = db.Column(db.DateTime, default=utcnow) 135 | 136 | def __init__(self, asset_id, filename, file_path, file_type=None, file_size=None): 137 | self.asset_id = asset_id 138 | self.filename = filename 139 | self.file_path = file_path 140 | self.file_type = file_type 141 | self.file_size = file_size 142 | -------------------------------------------------------------------------------- /app/models/audit.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from datetime import datetime 3 | import uuid 4 | 5 | class AuditLog(db.Model): 6 | __tablename__ = 'audit_logs' 7 | 8 | id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) 9 | timestamp = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True) 10 | user_id = db.Column(db.String(36), db.ForeignKey('users.id')) 11 | username = db.Column(db.String(80)) # Denormalized for historical tracking 12 | action = db.Column(db.String(50), nullable=False, index=True) # CREATE, UPDATE, DELETE, LOGIN, etc. 13 | resource_type = db.Column(db.String(50), nullable=False, index=True) # User, Asset, Group, etc. 14 | resource_id = db.Column(db.String(36), index=True) # ID of the affected resource 15 | description = db.Column(db.Text) # Human-readable description of the action 16 | details = db.Column(db.Text) # JSON string with detailed changes 17 | ip_address = db.Column(db.String(45)) # Support for IPv6 18 | user_agent = db.Column(db.String(255)) # Browser/client information 19 | status = db.Column(db.String(20), default='success') # success, failure, warning, etc. 20 | 21 | # Relationship with user 22 | user = db.relationship('User', backref=db.backref('audit_logs', lazy='dynamic')) 23 | 24 | def __init__(self, action, resource_type, user=None, user_id=None, username=None, resource_id=None, 25 | description=None, details=None, ip_address=None, user_agent=None, status='success'): 26 | self.action = action 27 | self.resource_type = resource_type 28 | self.resource_id = resource_id 29 | self.description = description 30 | self.details = details 31 | self.ip_address = ip_address 32 | self.user_agent = user_agent 33 | self.status = status 34 | 35 | # Set user information 36 | if user: 37 | self.user_id = user.id 38 | self.username = user.username 39 | elif user_id: 40 | self.user_id = user_id 41 | self.username = username 42 | else: 43 | self.username = 'System' 44 | -------------------------------------------------------------------------------- /app/models/group.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | import uuid 3 | from datetime import datetime 4 | 5 | # Group-Permission association table 6 | group_permission = db.Table('group_permission', 7 | db.Column('group_id', db.String(36), db.ForeignKey('groups.id')), 8 | db.Column('permission_id', db.String(36), db.ForeignKey('permissions.id')) 9 | ) 10 | 11 | # Asset-Group association table 12 | asset_group = db.Table('asset_group', 13 | db.Column('asset_id', db.String(36), db.ForeignKey('assets.id')), 14 | db.Column('group_id', db.String(36), db.ForeignKey('groups.id')) 15 | ) 16 | 17 | class Group(db.Model): 18 | __tablename__ = 'groups' 19 | 20 | id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) 21 | name = db.Column(db.String(80), unique=True, nullable=False) 22 | description = db.Column(db.Text) 23 | created_at = db.Column(db.DateTime, default=datetime.utcnow) 24 | updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 25 | 26 | # Relationship with permissions 27 | permissions = db.relationship('Permission', secondary=group_permission, backref=db.backref('groups', lazy='dynamic')) 28 | 29 | def __init__(self, name, description=None): 30 | self.name = name 31 | self.description = description 32 | 33 | class Permission(db.Model): 34 | __tablename__ = 'permissions' 35 | 36 | id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) 37 | name = db.Column(db.String(80), unique=True, nullable=False) 38 | description = db.Column(db.Text) 39 | 40 | def __init__(self, name, description=None): 41 | self.name = name 42 | self.description = description 43 | -------------------------------------------------------------------------------- /app/models/notification.py: -------------------------------------------------------------------------------- 1 | # app/models/notification.py - Corrected version 2 | from app import db 3 | import uuid 4 | from app.utils.time_utils import utcnow 5 | 6 | class NotificationSetting(db.Model): 7 | __tablename__ = 'notification_settings' 8 | 9 | id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) 10 | user_id = db.Column(db.String(36), db.ForeignKey('users.id')) 11 | 12 | # Basic notification frequency settings (legacy/user preferences) 13 | frequency = db.Column(db.String(20), default='once') # 'once', 'daily', 'twice_daily', 'weekly' 14 | day_of_week = db.Column(db.Integer, default=1) # 0=Monday, 6=Sunday 15 | preferred_time = db.Column(db.String(5), default='09:00') # HH:MM format 16 | preferred_second_time = db.Column(db.String(5), default='15:00') # For twice_daily 17 | 18 | # Enhanced alert system settings 19 | initial_alert_enabled = db.Column(db.Boolean, default=True) 20 | initial_alert_days = db.Column(db.Integer, default=30) 21 | initial_alert_time = db.Column(db.String(5), default='09:00') 22 | 23 | secondary_alert_enabled = db.Column(db.Boolean, default=True) 24 | secondary_alert_days = db.Column(db.Integer, default=15) 25 | secondary_frequency = db.Column(db.String(20), default='daily') # 'daily', 'twice_daily', 'weekly', 'custom' 26 | secondary_custom_days = db.Column(db.Integer, default=1) 27 | secondary_day_of_week = db.Column(db.Integer, default=1) 28 | secondary_alert_time = db.Column(db.String(5), default='09:00') 29 | 30 | # Scheduler settings (only used for system defaults) 31 | scheduler_frequency_type = db.Column(db.String(10), default='hours') # 'minutes' or 'hours' 32 | scheduler_frequency_value = db.Column(db.Integer, default=1) # Value for the frequency 33 | scheduler_enabled = db.Column(db.Boolean, default=True) # Enable/disable scheduler 34 | 35 | # System-level settings (global default if user_id is NULL) 36 | is_system_default = db.Column(db.Boolean, default=False) 37 | 38 | created_at = db.Column(db.DateTime, default=utcnow) 39 | updated_at = db.Column(db.DateTime, default=utcnow, onupdate=utcnow) 40 | 41 | # Relationship with user 42 | user = db.relationship('User', backref=db.backref('notification_setting', uselist=False)) 43 | 44 | def __init__(self, user_id=None, frequency='once', day_of_week=1, 45 | preferred_time='09:00', preferred_second_time='15:00', 46 | is_system_default=False): 47 | self.user_id = user_id 48 | self.frequency = frequency 49 | self.day_of_week = day_of_week 50 | self.preferred_time = preferred_time 51 | self.preferred_second_time = preferred_second_time 52 | self.is_system_default = is_system_default 53 | 54 | 55 | class NotificationLog(db.Model): 56 | __tablename__ = 'notification_logs' 57 | 58 | id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) 59 | asset_id = db.Column(db.String(36), db.ForeignKey('assets.id')) 60 | recipient_email = db.Column(db.String(255), nullable=False) 61 | sent_at = db.Column(db.DateTime, default=utcnow) 62 | status = db.Column(db.String(20), default='sent') # sent, failed, acknowledged 63 | response = db.Column(db.String(50), nullable=True) # renewed, will_not_renew, pending, disabled 64 | response_date = db.Column(db.DateTime, nullable=True) 65 | notification_type = db.Column(db.String(20), default='standard') # standard, initial, secondary 66 | 67 | # Relationship with asset 68 | asset = db.relationship('Asset', backref=db.backref('notification_logs', lazy='dynamic')) 69 | 70 | def __init__(self, asset_id, recipient_email, status='sent', notification_type='standard'): 71 | self.asset_id = asset_id 72 | self.recipient_email = recipient_email 73 | self.status = status 74 | self.notification_type = notification_type 75 | -------------------------------------------------------------------------------- /app/models/setting.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from datetime import datetime 3 | 4 | class Setting(db.Model): 5 | __tablename__ = 'settings' 6 | 7 | id = db.Column(db.String(64), primary_key=True) 8 | value = db.Column(db.Text) 9 | last_updated = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) 10 | 11 | def __init__(self, id, value=None): 12 | self.id = id 13 | self.value = value 14 | -------------------------------------------------------------------------------- /app/models/tag.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | import uuid 3 | from datetime import datetime 4 | 5 | class Tag(db.Model): 6 | __tablename__ = 'tags' 7 | 8 | id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) 9 | name = db.Column(db.String(50), unique=True, nullable=False) 10 | color = db.Column(db.String(7), default="#6c757d") # Default color as hex code 11 | created_at = db.Column(db.DateTime, default=datetime.utcnow) 12 | 13 | def __init__(self, name, color=None): 14 | self.name = name 15 | if color: 16 | self.color = color 17 | -------------------------------------------------------------------------------- /app/routes/.smbdeleteAAA7283c0d: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/app/routes/.smbdeleteAAA7283c0d -------------------------------------------------------------------------------- /app/routes/__init__.py: -------------------------------------------------------------------------------- 1 | # This file makes the directory a Python package 2 | -------------------------------------------------------------------------------- /app/routes/oidc_auth.py: -------------------------------------------------------------------------------- 1 | # app/routes/oidc_auth.py - OIDC Authentication Routes 2 | from flask import Blueprint, redirect, url_for, flash, request, current_app 3 | from flask_login import login_user, current_user 4 | from app import db 5 | from app.models.user import User 6 | from app.models.group import Group 7 | from app.utils.oidc import oidc_client 8 | from app.utils.audit import log_login, log_user_change, log_activity 9 | 10 | oidc_auth = Blueprint('oidc_auth', __name__) 11 | 12 | @oidc_auth.route('/auth/oidc/login') 13 | def oidc_login(): 14 | """Initiate OIDC login""" 15 | if not oidc_client.is_enabled(): 16 | flash('SSO login is not available.', 'warning') 17 | return redirect(url_for('auth.login')) 18 | 19 | if current_user.is_authenticated: 20 | return redirect(url_for('asset.dashboard')) 21 | 22 | try: 23 | return oidc_client.generate_auth_url() 24 | except Exception as e: 25 | current_app.logger.error(f"OIDC login error: {str(e)}") 26 | flash('SSO login failed. Please try again or use regular login.', 'danger') 27 | return redirect(url_for('auth.login')) 28 | 29 | @oidc_auth.route('/auth/oidc/callback') 30 | def oidc_callback(): 31 | """Handle OIDC callback""" 32 | if not oidc_client.is_enabled(): 33 | flash('SSO login is not available.', 'warning') 34 | return redirect(url_for('auth.login')) 35 | 36 | # Get authorization code and state 37 | code = request.args.get('code') 38 | state = request.args.get('state') 39 | error = request.args.get('error') 40 | 41 | if error: 42 | current_app.logger.error(f"OIDC callback error: {error}") 43 | flash(f'SSO login failed: {error}', 'danger') 44 | return redirect(url_for('auth.login')) 45 | 46 | if not code: 47 | flash('No authorization code received.', 'danger') 48 | return redirect(url_for('auth.login')) 49 | 50 | try: 51 | # Handle OIDC callback 52 | user_info = oidc_client.handle_callback(code, state) 53 | 54 | # Extract user data 55 | user_data = oidc_client.extract_user_data(user_info) 56 | 57 | if not user_data['email']: 58 | flash('No email address received from SSO provider.', 'danger') 59 | return redirect(url_for('auth.login')) 60 | 61 | # Find or create user 62 | user = User.query.filter_by(email=user_data['email']).first() 63 | 64 | if not user: 65 | # Check if auto-creation is enabled 66 | config = oidc_client._get_oidc_config() 67 | if not config.get('auto_create_users', True): 68 | flash('Your account does not exist. Please contact an administrator.', 'danger') 69 | log_login(None, success=False, username=user_data['email'], 70 | description="SSO login failed - user auto-creation disabled") 71 | return redirect(url_for('auth.login')) 72 | 73 | # Create new user 74 | user = create_oidc_user(user_data) 75 | if not user: 76 | flash('Failed to create user account. Please contact an administrator.', 'danger') 77 | return redirect(url_for('auth.login')) 78 | else: 79 | # Update existing user with SSO data 80 | update_oidc_user(user, user_data) 81 | 82 | # Check if user is active 83 | if not user.is_active: 84 | flash('Your account has been deactivated. Please contact an administrator.', 'danger') 85 | log_login(user, success=False, description="SSO login failed - account deactivated") 86 | return redirect(url_for('auth.login')) 87 | 88 | # Log successful login 89 | login_user(user, remember=True) 90 | log_login(user, success=True, description="Successful SSO login") 91 | 92 | # Redirect to dashboard or next page 93 | next_page = request.args.get('next') 94 | if next_page and not next_page.startswith('/'): 95 | next_page = None 96 | 97 | return redirect(next_page or url_for('asset.dashboard')) 98 | 99 | except Exception as e: 100 | current_app.logger.error(f"OIDC callback processing error: {str(e)}") 101 | import traceback 102 | current_app.logger.error(traceback.format_exc()) 103 | flash('SSO login failed. Please try again or use regular login.', 'danger') 104 | return redirect(url_for('auth.login')) 105 | 106 | def create_oidc_user(user_data): 107 | """Create a new user from OIDC data""" 108 | try: 109 | # Determine role 110 | role_info = oidc_client.determine_user_role(user_data) 111 | 112 | # Generate username if not provided 113 | username = user_data['username'] 114 | if not username: 115 | username = user_data['email'].split('@')[0] 116 | 117 | # Ensure username is unique 118 | base_username = username 119 | counter = 1 120 | while User.query.filter_by(username=username).first(): 121 | username = f"{base_username}_{counter}" 122 | counter += 1 123 | 124 | # Create user 125 | user = User( 126 | username=username, 127 | email=user_data['email'], 128 | password='', # No password for SSO users 129 | first_name=user_data.get('first_name', ''), 130 | last_name=user_data.get('last_name', ''), 131 | is_admin=role_info['is_admin'], 132 | is_group_admin=role_info['is_group_admin'] 133 | ) 134 | 135 | # Assign to default groups based on role 136 | assign_default_groups(user, role_info['role']) 137 | 138 | db.session.add(user) 139 | db.session.commit() 140 | 141 | # Log user creation 142 | log_user_change(user, "CREATE", 143 | description=f"SSO user created: {user.username} (Role: {user.role_display})") 144 | 145 | current_app.logger.info(f"Created new SSO user: {user.username} ({user.email})") 146 | return user 147 | 148 | except Exception as e: 149 | db.session.rollback() 150 | current_app.logger.error(f"Error creating SSO user: {str(e)}") 151 | return None 152 | 153 | def update_oidc_user(user, user_data): 154 | """Update existing user with OIDC data""" 155 | try: 156 | # Update user information 157 | old_data = { 158 | 'first_name': user.first_name, 159 | 'last_name': user.last_name, 160 | 'email': user.email 161 | } 162 | 163 | user.first_name = user_data.get('first_name', user.first_name) 164 | user.last_name = user_data.get('last_name', user.last_name) 165 | 166 | # Update role if configured to do so 167 | config = oidc_client._get_oidc_config() 168 | if config.get('update_roles_on_login', False): 169 | role_info = oidc_client.determine_user_role(user_data) 170 | user.is_admin = role_info['is_admin'] 171 | user.is_group_admin = role_info['is_group_admin'] 172 | 173 | new_data = { 174 | 'first_name': user.first_name, 175 | 'last_name': user.last_name, 176 | 'email': user.email 177 | } 178 | 179 | db.session.commit() 180 | 181 | # Log if anything changed 182 | if old_data != new_data: 183 | log_user_change(user, "UPDATE", old_data, new_data, 184 | description=f"SSO user updated: {user.username}") 185 | 186 | except Exception as e: 187 | db.session.rollback() 188 | current_app.logger.error(f"Error updating SSO user: {str(e)}") 189 | 190 | def assign_default_groups(user, role): 191 | """Assign user to default groups based on role""" 192 | try: 193 | if role == 'admin': 194 | group = Group.query.filter_by(name='Administrators').first() 195 | elif role == 'group_admin': 196 | group = Group.query.filter_by(name='Group Administrators').first() 197 | else: 198 | group = Group.query.filter_by(name='Users').first() 199 | 200 | if group: 201 | user.groups.append(group) 202 | except Exception as e: 203 | current_app.logger.error(f"Error assigning default groups: {str(e)}") 204 | -------------------------------------------------------------------------------- /app/routes/static_pages.py: -------------------------------------------------------------------------------- 1 | from flask import Blueprint, render_template 2 | 3 | static_pages = Blueprint('static_pages', __name__) 4 | 5 | @static_pages.route('/about') 6 | def about(): 7 | return render_template('static_pages/about.html') 8 | -------------------------------------------------------------------------------- /app/static/css/style.css: -------------------------------------------------------------------------------- 1 | /* Main layout */ 2 | html, body { 3 | height: 100%; 4 | } 5 | 6 | body { 7 | display: flex; 8 | flex-direction: column; 9 | background-color: #f5f5f5; 10 | } 11 | 12 | .container-fluid { 13 | flex: 1 0 auto; 14 | } 15 | 16 | .footer { 17 | flex-shrink: 0; 18 | } 19 | 20 | /* Sidebar */ 21 | .sidebar { 22 | position: fixed; 23 | top: 56px; 24 | bottom: 0; 25 | left: 0; 26 | z-index: 100; 27 | padding: 0; 28 | box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); 29 | overflow-y: auto; 30 | } 31 | 32 | @media (max-width: 767.98px) { 33 | .sidebar { 34 | position: static; 35 | height: auto; 36 | } 37 | } 38 | 39 | /* Invalid Characters */ 40 | .invalid-char { 41 | background-color: #ffe6e6; 42 | border-color: #dc3545; 43 | } 44 | .validation-message { 45 | color: #dc3545; 46 | font-size: 0.875em; 47 | margin-top: 0.25rem; 48 | } 49 | 50 | /* Main content */ 51 | main { 52 | margin-bottom: 60px; 53 | } 54 | 55 | /* Cards */ 56 | .card { 57 | border-radius: 0.375rem; 58 | box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); 59 | margin-bottom: 1.5rem; 60 | border: none; 61 | } 62 | 63 | .card-header { 64 | background-color: rgba(0, 0, 0, 0.03); 65 | border-bottom: 1px solid rgba(0, 0, 0, 0.125); 66 | } 67 | 68 | /* Tables */ 69 | .table th { 70 | font-weight: 500; 71 | text-transform: uppercase; 72 | font-size: 0.75rem; 73 | letter-spacing: 0.5px; 74 | } 75 | 76 | /* Status colors */ 77 | .status-active { 78 | color: #28a745; 79 | } 80 | 81 | .status-expiring { 82 | color: #ffc107; 83 | } 84 | 85 | .status-expired { 86 | color: #dc3545; 87 | } 88 | 89 | /* Form styles */ 90 | .form-control:focus, .form-select:focus { 91 | border-color: #86b7fe; 92 | box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); 93 | } 94 | 95 | /* Auth pages */ 96 | .auth-form { 97 | width: 100%; 98 | max-width: 400px; 99 | padding: 15px; 100 | margin: auto; 101 | } 102 | 103 | .auth-form .card { 104 | border-radius: 0.5rem; 105 | } 106 | 107 | /* Tag badges */ 108 | .badge { 109 | font-weight: 500; 110 | padding: 0.35em 0.65em; 111 | border-radius: 0.25rem; 112 | } 113 | 114 | /* Detail view */ 115 | .detail-header { 116 | background-color: #f8f9fa; 117 | border-radius: 0.375rem; 118 | padding: 1.5rem; 119 | margin-bottom: 1.5rem; 120 | } 121 | 122 | /* File attachment styles */ 123 | .attachment-item { 124 | display: flex; 125 | align-items: center; 126 | padding: 0.5rem; 127 | border: 1px solid #dee2e6; 128 | border-radius: 0.25rem; 129 | margin-bottom: 0.5rem; 130 | } 131 | 132 | .attachment-icon { 133 | font-size: 1.25rem; 134 | margin-right: 0.5rem; 135 | } 136 | 137 | /* Progress bar for warranty */ 138 | .warranty-progress { 139 | height: 10px; 140 | border-radius: 5px; 141 | } 142 | 143 | /* Timeline for warranty history */ 144 | .timeline { 145 | position: relative; 146 | padding-left: 30px; 147 | } 148 | 149 | .timeline:before { 150 | content: ''; 151 | position: absolute; 152 | left: 10px; 153 | top: 0; 154 | height: 100%; 155 | width: 2px; 156 | background-color: #dee2e6; 157 | } 158 | 159 | .timeline-item { 160 | position: relative; 161 | margin-bottom: 1.5rem; 162 | } 163 | 164 | .timeline-item:before { 165 | content: ''; 166 | position: absolute; 167 | left: -30px; 168 | top: 0; 169 | width: 12px; 170 | height: 12px; 171 | border-radius: 50%; 172 | background-color: #6c757d; 173 | border: 2px solid #fff; 174 | } 175 | 176 | .timeline-item.active:before { 177 | background-color: #28a745; 178 | } 179 | 180 | .timeline-item.warning:before { 181 | background-color: #ffc107; 182 | } 183 | 184 | .timeline-item.danger:before { 185 | background-color: #dc3545; 186 | } 187 | /* Add these styles to your style.css file */ 188 | 189 | /* Navbar fixed positioning with significantly reduced height */ 190 | .navbar { 191 | position: fixed; 192 | top: 0; 193 | right: 0; 194 | left: 0; 195 | z-index: 1030; 196 | padding: 0.15rem 1rem; /* Very compact padding */ 197 | min-height: 45px; /* Smaller minimum height */ 198 | } 199 | 200 | /* Reduce the container-fluid padding inside navbar */ 201 | .navbar .container-fluid { 202 | padding-top: 0; 203 | padding-bottom: 0; 204 | } 205 | 206 | /* Make all navbar elements more compact */ 207 | .navbar-brand { 208 | padding: 0; 209 | margin-right: 0.5rem; 210 | font-size: 1rem; 211 | display: flex; 212 | align-items: center; 213 | } 214 | 215 | .navbar .nav-link { 216 | padding: 0.2rem 0.5rem; 217 | font-size: 0.875rem; /* Smaller font */ 218 | } 219 | 220 | /* Make form elements in navbar smaller */ 221 | .navbar .form-control, 222 | .navbar .btn { 223 | padding: 0.25rem 0.5rem; 224 | font-size: 0.875rem; 225 | height: calc(1.5rem + 0.5rem + 2px); 226 | } 227 | 228 | /* Smaller dropdown toggle button */ 229 | .navbar .dropdown-toggle { 230 | padding: 0.2rem 0.5rem; 231 | font-size: 0.875rem; 232 | } 233 | 234 | /* Adjust spacing in nav items list */ 235 | .navbar-nav { 236 | margin-top: 0; 237 | margin-bottom: 0; 238 | } 239 | 240 | .navbar-nav .nav-item { 241 | margin-top: 0; 242 | margin-bottom: 0; 243 | } 244 | 245 | /* Fix for navbar toggler button to be smaller */ 246 | .navbar-toggler { 247 | padding: 0.15rem 0.3rem; 248 | font-size: 0.875rem; 249 | } 250 | 251 | /* Main content adjustment to prevent overlap with fixed navbar */ 252 | .container-fluid:not(.navbar .container-fluid) { 253 | padding-top: 100px; /* Match the minimum navbar height */ 254 | } 255 | 256 | /* Sidebar adjustment for fixed navbar */ 257 | .sidebar { 258 | position: fixed; 259 | top: 38px; /* Match the minimum navbar height */ 260 | bottom: 0; 261 | left: 0; 262 | z-index: 100; 263 | padding: 0; 264 | box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1); 265 | overflow-y: auto; 266 | } 267 | 268 | .page-title, h1.h2, .card-header h5.card-title { 269 | margin-top: 1.5rem; 270 | margin-bottom: 1.5rem; 271 | } 272 | 273 | /* Responsive adjustments */ 274 | @media (max-width: 767.98px) { 275 | .sidebar { 276 | position: static; 277 | height: auto; 278 | padding-top: 0; 279 | } 280 | 281 | main { 282 | margin-top: 1.5rem; 283 | } 284 | } 285 | 286 | /* Make sure the main content has appropriate spacing */ 287 | main { 288 | margin-bottom: 60px; 289 | } 290 | -------------------------------------------------------------------------------- /app/static/image/AL_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/app/static/image/AL_logo.png -------------------------------------------------------------------------------- /app/static/js/main.js: -------------------------------------------------------------------------------- 1 | // Main JS file for Asset Lookup 2 | 3 | document.addEventListener('DOMContentLoaded', function() { 4 | // Initialize tooltips 5 | const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); 6 | tooltipTriggerList.map(function (tooltipTriggerEl) { 7 | return new bootstrap.Tooltip(tooltipTriggerEl); 8 | }); 9 | 10 | // Initialize popovers 11 | const popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')); 12 | popoverTriggerList.map(function (popoverTriggerEl) { 13 | return new bootstrap.Popover(popoverTriggerEl); 14 | }); 15 | 16 | // Search autocomplete 17 | setupSearchAutocomplete(); 18 | 19 | // File input custom styling 20 | setupFileInputs(); 21 | 22 | // Tag management 23 | setupTagManagement(); 24 | }); 25 | 26 | // Search autocomplete 27 | function setupSearchAutocomplete() { 28 | const searchInput = document.getElementById('navbarSearch'); 29 | if (!searchInput) return; 30 | 31 | let searchTimeout; 32 | 33 | searchInput.addEventListener('input', function() { 34 | clearTimeout(searchTimeout); 35 | 36 | const query = this.value.trim(); 37 | if (query.length < 2) return; 38 | 39 | searchTimeout = setTimeout(() => { 40 | fetch(`/api/search/assets?q=${encodeURIComponent(query)}`) 41 | .then(response => response.json()) 42 | .then(data => { 43 | const resultsDropdown = document.getElementById('searchResults'); 44 | resultsDropdown.innerHTML = ''; 45 | 46 | if (data.length === 0) { 47 | resultsDropdown.innerHTML = ''; 48 | return; 49 | } 50 | 51 | data.forEach(asset => { 52 | const item = document.createElement('a'); 53 | item.href = `/assets/${asset.id}`; 54 | item.className = 'dropdown-item'; 55 | 56 | let statusBadge = ''; 57 | if (asset.status === 'Active') { 58 | statusBadge = 'Active'; 59 | } else if (asset.status === 'Expiring Soon') { 60 | statusBadge = 'Expiring Soon'; 61 | } else { 62 | statusBadge = 'Expired'; 63 | } 64 | 65 | item.innerHTML = ` 66 |
67 |
68 |
${asset.name}
69 |
${asset.model}
70 |
71 | ${statusBadge} 72 |
73 | `; 74 | 75 | resultsDropdown.appendChild(item); 76 | }); 77 | }); 78 | }, 300); 79 | }); 80 | } 81 | 82 | // File input styling 83 | function setupFileInputs() { 84 | const fileInputs = document.querySelectorAll('.custom-file-input'); 85 | fileInputs.forEach(input => { 86 | input.addEventListener('change', function(e) { 87 | const fileLabel = this.nextElementSibling; 88 | 89 | if (this.files && this.files.length > 1) { 90 | fileLabel.textContent = `${this.files.length} files selected`; 91 | } else if (this.files && this.files.length === 1) { 92 | fileLabel.textContent = this.files[0].name; 93 | } else { 94 | fileLabel.textContent = 'Choose file(s)'; 95 | } 96 | }); 97 | }); 98 | } 99 | 100 | // Tag management 101 | function setupTagManagement() { 102 | const tagForm = document.getElementById('tagForm'); 103 | if (!tagForm) return; 104 | 105 | const tagNameInput = document.getElementById('tagName'); 106 | const tagColorInput = document.getElementById('tagColor'); 107 | const tagPreview = document.getElementById('tagPreview'); 108 | 109 | // Update tag preview when inputs change 110 | tagNameInput.addEventListener('input', updateTagPreview); 111 | tagColorInput.addEventListener('input', updateTagPreview); 112 | 113 | function updateTagPreview() { 114 | tagPreview.textContent = tagNameInput.value || 'Tag Preview'; 115 | tagPreview.style.backgroundColor = tagColorInput.value; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /app/static/uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/app/static/uploads/.gitkeep -------------------------------------------------------------------------------- /app/templates/admin/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Admin Dashboard{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Admin Dashboard 9 |

10 |
11 | 12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 |
Users
20 |

{{ user_count }}

21 |

{{ active_user_count }} active

22 |
23 |
24 | 25 |
26 |
27 |
28 | View all 29 |
30 |
31 |
32 |
33 | 34 |
35 |
36 |
37 |
38 |
39 |
Groups
40 |

{{ group_count }}

41 |

User groups

42 |
43 |
44 | 45 |
46 |
47 |
48 | View all 49 |
50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 |
58 |
59 |
System Settings
60 |

61 |

Configuration

62 |
63 |
64 | 65 |
66 |
67 |
68 | View settings 69 |
70 |
71 |
72 |
73 |
74 | 75 |
76 |
77 | 101 |
102 | 103 |
104 |
105 |
106 |
System Information
107 |
108 |
109 | 110 |
111 |
Current Time
112 |
113 |
114 | 115 |
116 |
Database Status
117 |
Connected
118 |
119 | 120 |
121 |
Email Notifications
122 |
123 | {% if config.MAIL_SERVER and config.MAIL_USERNAME %} 124 | Configured 125 | {% else %} 126 | Not Configured 127 | {% endif %} 128 |
129 |
130 | 131 |
132 |
Registration
133 |
134 | {% if config.ALLOW_REGISTRATION %} 135 | Enabled 136 | {% else %} 137 | Disabled 138 | {% endif %} 139 |
140 |
141 |
142 |
143 |
144 |
145 | {% endblock %} 146 | 147 | {% block js %} 148 | 161 | {% endblock %} 162 | -------------------------------------------------------------------------------- /app/templates/admin/groups/create.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Create Group{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Create Group 9 |

10 |
11 | 12 |
13 |
14 |
15 |
16 | 17 | 18 |
19 | 20 |
21 | 22 | 23 |
24 | 25 |
26 | 27 |
28 | {% for permission in permissions %} 29 |
30 |
31 | 32 | 35 | {{ permission.description }} 36 |
37 |
38 | {% endfor %} 39 |
40 |
41 | 42 |
43 | 44 | Cancel 45 | 46 | 49 |
50 |
51 |
52 |
53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /app/templates/admin/groups/edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Edit Group{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Edit Group: {{ group.name }} 9 |

10 |
11 | 12 |
13 |
14 |
15 |
16 | 17 | 18 |
19 | 20 |
21 | 22 | 23 |
24 | 25 |
26 | 27 |
28 | {% for permission in permissions %} 29 |
30 |
31 | 32 | 35 | {{ permission.description }} 36 |
37 |
38 | {% endfor %} 39 |
40 |
41 | 42 |
43 | 44 | Cancel 45 | 46 | 49 |
50 |
51 |
52 |
53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /app/templates/admin/groups/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Group Management{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Group Management 9 |

10 |
11 | 12 | Add Group 13 | 14 |
15 |
16 | 17 |
18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% for group in groups %} 33 | 34 | 35 | 36 | 43 | 44 | 45 | 77 | 78 | {% endfor %} 79 | 80 |
NameDescriptionPermissionsUsersCreated
{{ group.name }}{{ group.description }} 37 | {% for permission in group.permissions %} 38 | {{ permission.name }} 39 | {% else %} 40 | No permissions 41 | {% endfor %} 42 | {{ group.users.count() }}{{ group.created_at.strftime('%Y-%m-%d') }} 46 | 47 | 48 | 49 | 50 | 54 | 55 | 56 | 76 |
81 |
82 |
83 |
84 | {% endblock %} 85 | -------------------------------------------------------------------------------- /app/templates/admin/users/create.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Create User{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Create User 9 |

10 |
11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 | 19 | 20 |
21 |
22 |
23 |
24 | 25 | 26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 | 34 | 35 |
36 |
37 |
38 |
39 | 40 | 41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 | 49 | 50 |
51 |
52 |
53 |
54 | 55 | 56 |
57 |
58 |
59 | 60 |
61 |
62 |
63 |
64 | 65 | 66 |
67 | Full access to all system functions 68 |
69 |
70 |
71 |
72 |
73 | 74 | 75 |
76 | Can create/edit assets and assign their own groups 77 |
78 |
79 |
80 |
81 |
82 | 83 | 84 |
85 | Inactive users cannot log in 86 |
87 |
88 |
89 | 90 |
91 | 92 | 97 | Hold Ctrl (Windows) or Cmd (Mac) to select multiple groups 98 |
99 | 100 | 101 |
102 |
103 |
User Role Permissions
104 |
105 |
106 | Administrator: 107 |
    108 |
  • Full system access
  • 109 |
  • Can see all assets
  • 110 |
  • Can assign any groups to assets
  • 111 |
  • Can delete assets
  • 112 |
  • Can manage users and groups
  • 113 |
114 |
115 |
116 | Group Admin: 117 |
    118 |
  • Can create and edit assets
  • 119 |
  • Can only assign their own groups to assets
  • 120 |
  • Can see assets assigned to their groups
  • 121 |
  • Cannot delete assets
  • 122 |
  • Cannot manage users or groups
  • 123 |
124 |
125 |
126 | Regular User: 127 |
    128 |
  • Can create and edit assets
  • 129 |
  • Can only assign their own groups to assets
  • 130 |
  • Can see assets assigned to them or their groups
  • 131 |
  • Cannot delete assets
  • 132 |
  • Cannot manage users or groups
  • 133 |
134 |
135 |
136 |
137 |
138 | 139 |
140 | 141 | Cancel 142 | 143 | 146 |
147 |
148 |
149 |
150 | 151 | 176 | {% endblock %} 177 | -------------------------------------------------------------------------------- /app/templates/admin/users/edit.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Edit User{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Edit User: {{ user.username }} 9 | {{ user.role_display }} 10 |

11 |
12 | 13 |
14 |
15 |
16 |
17 |
18 |
19 | 20 | 21 |
22 |
23 |
24 |
25 | 26 | 27 |
28 |
29 |
30 | 31 |
32 |
33 |
34 | 35 | 36 |
37 |
38 |
39 |
40 | 41 | 42 |
43 |
44 |
45 | 46 |
47 |
48 |
49 | 50 | 51 |
52 |
53 |
54 |
55 | 56 | 57 |
58 |
59 |
60 | 61 |
62 |
63 |
64 |
65 | 66 | 67 |
68 | Full access to all system functions 69 |
70 |
71 |
72 |
73 |
74 | 75 | 76 |
77 | Can create/edit assets and assign their own groups 78 |
79 |
80 |
81 |
82 |
83 | 84 | 85 |
86 | Inactive users cannot log in 87 |
88 |
89 |
90 | 91 |
92 | 93 | 98 | Hold Ctrl (Windows) or Cmd (Mac) to select multiple groups 99 |
100 | 101 | 102 |
103 |
104 |
User Role Permissions
105 |
106 |
107 | Administrator: 108 |
    109 |
  • Full system access
  • 110 |
  • Can see all assets
  • 111 |
  • Can assign any groups to assets
  • 112 |
  • Can delete assets
  • 113 |
  • Can manage users and groups
  • 114 |
115 |
116 |
117 | Group Admin: 118 |
    119 |
  • Can create and edit assets
  • 120 |
  • Can only assign their own groups to assets
  • 121 |
  • Can see assets assigned to their groups
  • 122 |
  • Cannot delete assets
  • 123 |
  • Cannot manage users or groups
  • 124 |
125 |
126 |
127 | Regular User: 128 |
    129 |
  • Can create and edit assets
  • 130 |
  • Can only assign their own groups to assets
  • 131 |
  • Can see assets assigned to them or their groups
  • 132 |
  • Cannot delete assets
  • 133 |
  • Cannot manage users or groups
  • 134 |
135 |
136 |
137 |
138 |
139 | 140 |
141 | 142 | Cancel 143 | 144 | 147 |
148 |
149 |
150 |
151 | 152 | 178 | {% endblock %} 179 | -------------------------------------------------------------------------------- /app/templates/admin/users/list.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}User Management{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | User Management 9 |

10 |
11 | 12 | Add User 13 | 14 |
15 |
16 | 17 |
18 |
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | {% for user in users %} 35 | 36 | 37 | 38 | 39 | 46 | 55 | 62 | 63 | 97 | 98 | {% endfor %} 99 | 100 |
UsernameNameEmailGroupsRoleStatusCreated
{{ user.username }}{{ user.full_name }}{{ user.email }} 40 | {% for group in user.groups %} 41 | {{ group.name }} 42 | {% else %} 43 | No groups 44 | {% endfor %} 45 | 47 | {% if user.is_admin %} 48 | Administrator 49 | {% elif user.is_group_admin %} 50 | Group Admin 51 | {% else %} 52 | User 53 | {% endif %} 54 | 56 | {% if user.is_active %} 57 | Active 58 | {% else %} 59 | Inactive 60 | {% endif %} 61 | {{ user.created_at.strftime('%Y-%m-%d') }} 64 | 65 | 66 | 67 | 68 | {% if user.id != current_user.id %} 69 | 73 | 74 | 75 | 95 | {% endif %} 96 |
101 |
102 |
103 |
104 | 105 | 106 |
107 |
108 |
User Role Explanation
109 |
110 |
111 | Administrator 112 |
    113 |
  • Full system access
  • 114 |
  • Can see and manage all assets
  • 115 |
  • Can assign any groups to assets
  • 116 |
  • Can delete assets
  • 117 |
  • Can manage users and groups
  • 118 |
119 |
120 |
121 | Group Admin 122 |
    123 |
  • Can create and edit assets
  • 124 |
  • Can only assign their own groups to assets
  • 125 |
  • Can see assets assigned to their groups
  • 126 |
  • Can delete assets assigned to their groups
  • 127 |
  • Cannot manage users or groups
  • 128 |
129 |
130 |
131 | User 132 |
    133 |
  • Can create and edit assets
  • 134 |
  • Can only assign their own groups to assets
  • 135 |
  • Can see assets assigned to them or their groups
  • 136 |
  • Cannot delete assets
  • 137 |
  • Cannot manage users or groups
  • 138 |
139 |
140 |
141 |
142 |
143 | {% endblock %} 144 | -------------------------------------------------------------------------------- /app/templates/assets/import.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | {% block content %} 3 |
4 |
5 |
6 |
7 |
8 |
Import Assets
9 |
10 |
11 |

Upload a CSV file to import multiple assets at once.

12 | 13 |
14 |
15 | 16 | 17 |
18 | The CSV file should have the following columns: 19 |
    20 |
  • Product Name (required)
  • 21 |
  • Product Model
  • 22 |
  • Internal Asset Name
  • 23 |
  • Serial Number
  • 24 |
  • Purchase Date (required, format: YYYY-MM-DD)
  • 25 |
  • Price (numeric)
  • 26 |
  • Currency Symbol (e.g., $, €, £, ¥, ₹, ₱)
  • 27 |
  • Warranty Duration (months) (required, numeric)
  • 28 |
  • Location
  • 29 |
  • Vendor Company
  • 30 |
  • Vendor Email
  • 31 |
  • Notes
  • 32 |
  • Disposal Date (format: YYYY-MM-DD)
  • 33 |
  • Alert Period (days) (numeric, default: 30)
  • 34 |
  • Tags (comma-separated list)
  • 35 |
36 |
37 |
38 | 39 |
40 | 41 | Cancel 42 |
43 |
44 | 45 |
46 |
Sample CSV Format
47 |
48 | Product Name,Product Model,Internal Asset Name,Serial Number,Purchase Date,Price,Currency Symbol,Warranty Duration (months),Location,Vendor Company,Vendor Email,Notes,Disposal Date,Alert Period (days),Tags
49 | Laptop Dell XPS,XPS 15,DEV-LAPTOP-001,SN12345,2023-01-15,1299.99,$,24,Main Office,Dell,support@dell.com,Developer laptop,,30,IT Equipment,Development
50 | Monitor LG,27GL850-B,DEV-MON-002,MN67890,2023-01-15,399.99,€,12,Main Office,LG,support@lg.com,Developer monitor,,30,IT Equipment,Peripherals
51 |                         
52 |
53 |
54 |
55 |
56 |
57 |
58 | {% endblock %} 59 | -------------------------------------------------------------------------------- /app/templates/assets/tags.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block title %}Tags{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Tags 9 |

10 |
11 | 14 |
15 |
16 | 17 | 18 |
19 |
20 |
21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | {% for tag in tags %} 33 | 34 | 37 | 41 | 42 | 43 | 109 | 110 | {% else %} 111 | 112 | 117 | 118 | {% endfor %} 119 | 120 |
TagColorAssetsCreated
35 | {{ tag.name }} 36 | 38 | {{ tag.color }} 39 |
40 |
{{ tag.assets.count() }}{{ tag.created_at.strftime('%Y-%m-%d') }} 44 | 48 | 52 | 53 | 54 | 86 | 87 | 88 | 108 |
113 |
114 | No tags found 115 |
116 |
121 |
122 |
123 |
124 | 125 | 126 | 158 | {% endblock %} 159 | 160 | {% block js %} 161 | 194 | {% endblock %} 195 | -------------------------------------------------------------------------------- /app/templates/auth/2fa_admin_overview.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}2FA User Overview{% endblock %} 4 | 5 | {% block content %} 6 |
7 |

8 | Two-Factor Authentication Overview 9 |

10 |
11 | 12 | System Settings 13 | 14 |
15 |
16 | 17 | 18 |
19 |
20 |
21 |
22 |
23 |
24 |
Total Active Users
25 |

{{ total_users }}

26 |
27 |
28 | 29 |
30 |
31 |
32 |
33 |
34 | 35 |
36 |
37 |
38 |
39 |
40 |
2FA Enabled
41 |

{{ users_with_2fa }}

42 | 43 | {{ "%.1f"|format((users_with_2fa / total_users * 100) if total_users > 0 else 0) }}% of users 44 | 45 |
46 |
47 | 48 |
49 |
50 |
51 |
52 |
53 | 54 |
55 |
56 |
57 |
58 |
59 |
2FA Pending
60 |

{{ users_without_2fa }}

61 | 62 | {{ "%.1f"|format((users_without_2fa / total_users * 100) if total_users > 0 else 0) }}% of users 63 | 64 |
65 |
66 | 67 |
68 |
69 |
70 |
71 |
72 |
73 | 74 | 75 |
76 |
77 |
78 |
2FA Adoption Progress
79 | {{ users_with_2fa }} / {{ total_users }} users 80 |
81 |
82 |
88 | {{ "%.1f"|format((users_with_2fa / total_users * 100) if total_users > 0 else 0) }}% 89 |
90 |
91 |
92 |
93 | 94 | 95 |
96 |
97 |
98 | User 2FA Status Details 99 |
100 |
101 |
102 |
103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | {% for user in users %} 116 | 117 | 134 | 135 | 144 | 159 | 169 | 174 | 175 | {% endfor %} 176 | 177 |
UserEmailRole2FA StatusSetup DateLast Login
118 |
119 |
120 | {% if user.two_factor_setup_complete %} 121 | 122 | {% else %} 123 | 124 | {% endif %} 125 |
126 |
127 |
{{ user.username }}
128 | {% if user.first_name or user.last_name %} 129 | {{ user.full_name }} 130 | {% endif %} 131 |
132 |
133 |
{{ user.email }} 136 | {% if user.is_admin %} 137 | Admin 138 | {% elif user.is_group_admin %} 139 | Group Admin 140 | {% else %} 141 | User 142 | {% endif %} 143 | 145 | {% if user.two_factor_setup_complete %} 146 | 147 | Enabled 148 | 149 | {% elif user.needs_2fa_setup() %} 150 | 151 | Setup Required 152 | 153 | {% else %} 154 | 155 | Not Required 156 | 157 | {% endif %} 158 | 160 | {% if user.two_factor_setup_complete %} 161 | 162 | 163 | {{ user.updated_at|format_datetime('%Y-%m-%d') }} 164 | 165 | {% else %} 166 | - 167 | {% endif %} 168 | 170 | 171 | {{ user.updated_at|format_datetime('%Y-%m-%d %H:%M') }} 172 | 173 |
178 |
179 |
180 |
181 | 182 | 183 |
184 |
185 |
186 | 2FA Information 187 |
188 |
189 |
190 |
191 |
192 |
How 2FA Works:
193 |
    194 |
  • Users install an authenticator app (Google Authenticator, Authy, etc.)
  • 195 |
  • They scan a QR code to add their account to the app
  • 196 |
  • During login, they enter their password + 6-digit code from the app
  • 197 |
  • Codes change every 30 seconds for maximum security
  • 198 |
199 |
200 |
201 |
Admin Actions:
202 |
    203 |
  • Enable/disable 2FA system-wide in System Settings
  • 204 |
  • Monitor user adoption progress on this page
  • 205 |
  • Users who haven't set up 2FA will be prompted on next login
  • 206 |
  • Contact support if users lose access to their authenticator app
  • 207 |
208 |
209 |
210 | 211 | {% if users_without_2fa > 0 %} 212 |
213 | 214 | {{ users_without_2fa }} user(s) still need to set up 2FA. 215 | They will be prompted to complete setup on their next login. 216 |
217 | {% endif %} 218 |
219 |
220 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}Login{% endblock %} 4 | 5 | {% block auth_content %} 6 |
7 |
8 |
9 |
10 |
11 |

12 | 13 | {% if show_2fa %} 14 | Two-Factor Authentication 15 | {% else %} 16 | Login to Asset Lookup 17 | {% endif %} 18 |

19 |
20 |
21 | {% if show_2fa %} 22 | 23 |
24 | 25 | Security Verification Required
26 | Please enter the 6-digit code from your authenticator app. 27 |
28 | 29 |
30 | 31 | 32 |
33 | 36 | 46 |
Enter the 6-digit code from your authenticator app
47 |
48 | 49 |
50 | 53 |
54 |
55 |
56 |
57 | 58 | 59 | Use Google Authenticator, Authy, or similar app 60 | 61 |
62 | 63 | {% else %} 64 | 65 |
66 |
67 | 68 | 75 |
76 | 77 |
78 | 79 | 84 |
85 | 86 |
87 | 91 | 94 |
95 | 96 |
97 | 100 |
101 |
102 | 103 |
104 | 105 | 110 | 111 | {% if config.ALLOW_REGISTRATION %} 112 | 117 | {% endif %} 118 | {% endif %} 119 |
120 |
121 |
122 |
123 |
124 | 125 | {% if show_2fa %} 126 | 127 | 159 | {% endif %} 160 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/auth/login_with_sso.html: -------------------------------------------------------------------------------- 1 | # app/templates/auth/login_with_sso.html - Updated login template 2 | {% extends "layout.html" %} 3 | 4 | {% block title %}Login{% endblock %} 5 | 6 | {% block auth_content %} 7 |
8 |
9 |
10 |
11 |
12 |

13 | 14 | {% if show_2fa %} 15 | Two-Factor Authentication 16 | {% else %} 17 | Login to Asset Lookup 18 | {% endif %} 19 |

20 |
21 |
22 | {% if show_2fa %} 23 | 24 | 25 | {% else %} 26 | 27 | {% if oidc_enabled %} 28 | 33 | 34 |
35 | or 36 |
37 | {% endif %} 38 | 39 | 40 |
41 |
42 | 43 | 50 |
51 | 52 |
53 | 54 | 59 |
60 | 61 |
62 | 66 | 69 |
70 | 71 |
72 | 75 |
76 |
77 | 78 |
79 | 80 | 85 | 86 | {% if config.ALLOW_REGISTRATION %} 87 | 92 | {% endif %} 93 | {% endif %} 94 |
95 |
96 |
97 |
98 |
99 | {% endblock %} 100 | -------------------------------------------------------------------------------- /app/templates/auth/register.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block auth_content %} 4 |
5 |
6 |
7 |

Create Account

8 |

Register for Asset Lookup

9 |
10 | 11 |
12 |
13 |
14 |
15 |
16 | 17 | 18 |
19 |
20 | 21 | 22 |
23 |
24 | 25 |
26 | 27 |
28 | 29 | 31 |
32 |
33 | 34 |
35 | 36 |
37 | 38 | 40 |
41 |
42 | 43 |
44 | 45 |
46 | 47 | 49 |
50 |
51 | 52 |
53 | 54 |
55 | 56 | 58 |
59 |
60 | 61 |
62 | 63 |
64 |
65 |
66 |
67 | 68 |
69 | Already have an account? Sign in 70 |
71 |
72 |
73 | {% endblock %} 74 | -------------------------------------------------------------------------------- /app/templates/auth/reset_password.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block auth_content %} 4 |
5 |
6 |
7 |

Create New Password

8 |

Enter your new password below

9 |
10 | 11 |
12 |
13 |
14 |
15 | 16 |
17 | 18 | 20 |
21 |
22 | 23 |
24 | 25 |
26 | 27 | 29 |
30 |
31 | 32 |
33 | 34 |
35 |
36 |
37 |
38 | 39 | 44 |
45 |
46 | {% endblock %} 47 | -------------------------------------------------------------------------------- /app/templates/auth/reset_password_request.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block auth_content %} 4 |
5 |
6 |
7 |

Reset Password

8 |

Enter your email to receive reset instructions

9 |
10 | 11 |
12 |
13 |
14 |
15 | 16 |
17 | 18 | 20 |
21 |
22 | 23 |
24 | 25 |
26 |
27 |
28 |
29 | 30 | 35 |
36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /app/templates/auth/setup_2fa.html: -------------------------------------------------------------------------------- 1 | {% extends "layout.html" %} 2 | 3 | {% block title %}Set Up Two-Factor Authentication{% endblock %} 4 | 5 | {% block content %} 6 |
7 |
8 |
9 |
10 |

11 | 12 | Set Up Two-Factor Authentication 13 |

14 |
15 |
16 |
17 | 18 | Enhanced Security Required
19 | Two-Factor Authentication (2FA) is now required for your account. This adds an extra layer of security by requiring a verification code from your mobile device in addition to your password. 20 |
21 | 22 |
23 |
24 |
Step 1: Install an Authenticator App
25 |

Download one of these apps on your smartphone:

26 |
    27 |
  • Google Authenticator
  • 28 |
  • Authy
  • 29 |
  • Microsoft Authenticator
  • 30 |
  • Any TOTP-compatible app
  • 31 |
32 |
33 | 34 |
35 |
Step 2: Scan QR Code
36 |
37 |
38 | 2FA QR Code 42 |
43 |
44 | 45 |
46 | 47 | Can't scan? Manually enter this secret key: 48 | 49 |
50 | 55 | 61 |
62 |
63 |
64 |
65 | 66 |
67 | 68 |
Step 3: Verify Setup
69 |

Enter the 6-digit code from your authenticator app to complete setup:

70 | 71 |
72 |
73 |
74 |
75 | 76 | 86 |
Enter the current 6-digit code from your app
87 |
88 | 89 |
90 | 93 |
94 |
95 |
96 |
97 | 98 |
99 | 100 | Important: Save your secret key in a secure location. If you lose access to your authenticator app, you'll need this key to set up 2FA again. 101 |
102 |
103 |
104 |
105 |
106 | 107 | 147 | {% endblock %} -------------------------------------------------------------------------------- /app/templates/email/reset_password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Password Reset 7 | 51 | 52 | 53 |
54 |
55 |

Asset Lookup

56 |
57 |
58 |

Password Reset Request

59 |

Hello {{ user.full_name or user.username }},

60 |

We received a request to reset your password for your Asset Lookup account. Click the button below to create a new password. This link is valid for 24 hours.

61 | 62 |
63 | Reset Your Password 64 |
65 | 66 |

If you didn't request a password reset, you can safely ignore this email. Your password will remain unchanged.

67 | 68 |

If the button above doesn't work, copy and paste the following link into your browser:

69 |

{{ reset_url }}

70 | 71 |

Best regards,
The Asset Lookup Team

72 |
73 | 77 |
78 | 79 | 80 | -------------------------------------------------------------------------------- /app/templates/email/reset_password.txt: -------------------------------------------------------------------------------- 1 | Password Reset Request - Asset Lookup 2 | ====================================== 3 | 4 | Hello {{ user.full_name or user.username }}, 5 | 6 | We received a request to reset your password for your Asset Lookup account. 7 | To create a new password, please visit the link below: 8 | 9 | {{ reset_url }} 10 | 11 | This link is valid for 24 hours. 12 | 13 | If you didn't request a password reset, you can safely ignore this email. 14 | Your password will remain unchanged. 15 | 16 | Best regards, 17 | The Asset Lookup Team 18 | 19 | ---------------------------------- 20 | This is an automated message, please do not reply. 21 | -------------------------------------------------------------------------------- /app/templates/email/warranty_alert.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Warranty Alert - {{ asset.product_name }} 8 | 166 | 167 | 168 |
169 |
170 |
171 | {{ urgency }} 172 |
173 |

Warranty Expiration Alert

174 |

{{ notification_type|title }} notification for asset warranty expiring soon

175 |
176 | 177 |
178 |

Asset Information

179 |
180 | Product Name: 181 | {{ asset.product_name }} 182 |
183 | {% if asset.product_model %} 184 |
185 | Model: 186 | {{ asset.product_model }} 187 |
188 | {% endif %} 189 | {% if asset.serial_number %} 190 |
191 | Serial Number: 192 | {{ asset.serial_number }} 193 |
194 | {% endif %} 195 |
196 | Purchase Date: 197 | {{ asset.purchase_date.strftime('%B %d, %Y') }} 198 |
199 |
200 | Warranty Duration: 201 | {{ asset.warranty_duration }} months 202 |
203 |
204 | Warranty Expires: 205 | {{ expiry_date }} 206 |
207 | {% if asset.location %} 208 |
209 | Location: 210 | {{ asset.location }} 211 |
212 | {% endif %} 213 | {% if asset.vendor_company %} 214 |
215 | Vendor: 216 | {{ asset.vendor_company }} 217 |
218 | {% endif %} 219 |
220 | 221 | {% if is_expired %} 222 |
223 |

⚠️ WARRANTY EXPIRED

224 |

This asset's warranty has already expired {{ days_remaining|abs }} days ago.

225 |

Please take appropriate action to either renew the warranty or update the asset status.

226 |
227 | {% elif is_critical %} 228 |
229 |

🚨 CRITICAL - WARRANTY EXPIRES VERY SOON

230 |

This asset's warranty expires in {{ days_remaining }} days!

231 |

Immediate action is required to avoid service interruption.

232 |
233 | {% elif notification_type == 'secondary' %} 234 |
235 |

⏰ FOLLOW-UP REMINDER

236 |

This is a follow-up reminder about this asset's warranty expiration.

237 |

The warranty expires in {{ days_remaining }} days. Please take action soon.

238 |
239 | {% endif %} 240 | 241 |
242 | {% if is_expired %} 243 |
EXPIRED
244 |
{{ days_remaining|abs }} days ago
245 | {% else %} 246 |
{{ days_remaining }}
247 |
248 | Day{{ 's' if days_remaining != 1 else '' }} Remaining 249 |
250 | {% endif %} 251 |
252 | 253 |
254 |

What would you like to do?

255 |

Click one of the buttons below to let us know your decision:

256 | 257 | 258 | ✅ Warranty Renewed 259 | 260 | 261 | 262 | ❌ Will Not Renew 263 | 264 | 265 | 266 | ⏳ Action Pending 267 | 268 | 269 | 270 | 🔕 Disable Notifications 271 | 272 |
273 | 274 | 284 |
285 | 286 | -------------------------------------------------------------------------------- /app/templates/email/warranty_alert.txt: -------------------------------------------------------------------------------- 1 | # app/templates/emails/enhanced_warranty_alert.txt 2 | 3 | ======================================== 4 | {{ urgency }} - WARRANTY EXPIRATION ALERT 5 | ======================================== 6 | 7 | {{ notification_type|title }} notification for asset warranty expiring soon 8 | 9 | ASSET INFORMATION: 10 | ------------------ 11 | Product Name: {{ asset.product_name }} 12 | {% if asset.product_model %}Model: {{ asset.product_model }} 13 | {% endif %}{% if asset.serial_number %}Serial Number: {{ asset.serial_number }} 14 | {% endif %}Purchase Date: {{ asset.purchase_date.strftime('%B %d, %Y') }} 15 | Warranty Duration: {{ asset.warranty_duration }} months 16 | Warranty Expires: {{ expiry_date }} 17 | {% if asset.location %}Location: {{ asset.location }} 18 | {% endif %}{% if asset.vendor_company %}Vendor: {{ asset.vendor_company }} 19 | {% endif %} 20 | 21 | {% if is_expired %} 22 | ======================================== 23 | ⚠️ WARRANTY EXPIRED 24 | ======================================== 25 | 26 | This asset's warranty has already expired {{ days_remaining|abs }} days ago. 27 | 28 | Please take appropriate action to either renew the warranty or update the asset status. 29 | 30 | {% elif is_critical %} 31 | ======================================== 32 | 🚨 CRITICAL - WARRANTY EXPIRES VERY SOON 33 | ======================================== 34 | 35 | This asset's warranty expires in {{ days_remaining }} days! 36 | 37 | Immediate action is required to avoid service interruption. 38 | 39 | {% elif notification_type == 'secondary' %} 40 | ======================================== 41 | ⏰ FOLLOW-UP REMINDER 42 | ======================================== 43 | 44 | This is a follow-up reminder about this asset's warranty expiration. 45 | 46 | The warranty expires in {{ days_remaining }} days. Please take action soon. 47 | 48 | {% endif %} 49 | 50 | COUNTDOWN: 51 | ---------- 52 | {% if is_expired %} 53 | EXPIRED - {{ days_remaining|abs }} days ago 54 | {% else %} 55 | {{ days_remaining }} day{{ 's' if days_remaining != 1 else '' }} remaining until warranty expires 56 | {% endif %} 57 | 58 | ACTIONS AVAILABLE: 59 | ------------------ 60 | Please visit one of the following links to let us know your decision: 61 | 62 | ✅ Warranty Renewed: 63 | {{ response_urls.renewed }} 64 | 65 | ❌ Will Not Renew: 66 | {{ response_urls.will_not_renew }} 67 | 68 | ⏳ Action Pending: 69 | {{ response_urls.pending }} 70 | 71 | 🔕 Disable Notifications for this Asset: 72 | {{ response_urls.disable_notifications }} 73 | 74 | ======================================== 75 | Asset Lookup System 76 | ======================================== 77 | 78 | This email was sent on {{ current_date }} to {{ recipient_email }} 79 | 80 | If you believe this email was sent in error, please contact your system administrator. 81 | 82 | This is an automated message from the Asset Lookup system. 83 | Please do not reply directly to this email. 84 | 85 | For support, please contact your system administrator. -------------------------------------------------------------------------------- /app/templates/emails/reset_password.html: -------------------------------------------------------------------------------- 1 | /app/app/templates/email/reset_password.html -------------------------------------------------------------------------------- /app/templates/emails/reset_password.txt: -------------------------------------------------------------------------------- 1 | /app/app/templates/email/reset_password.txt -------------------------------------------------------------------------------- /app/templates/emails/warranty_alert.html: -------------------------------------------------------------------------------- 1 | /app/app/templates/email/warranty_alert.html -------------------------------------------------------------------------------- /app/templates/emails/warranty_alert.txt: -------------------------------------------------------------------------------- 1 | /app/app/templates/email/warranty_alert.txt -------------------------------------------------------------------------------- /app/templates/errors/404.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block auth_content %} 4 |
5 |
6 |

404

7 |

Page Not Found

8 |

The page you are looking for does not exist or has been moved.

9 | 10 | Go to Dashboard 11 | 12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /app/templates/errors/500.html: -------------------------------------------------------------------------------- 1 | {% extends 'layout.html' %} 2 | 3 | {% block auth_content %} 4 |
5 |
6 |

500

7 |

Internal Server Error

8 |

Something went wrong on our end. Please try again later.

9 | 10 | Go to Login 11 | 12 |
13 |
14 | {% endblock %} 15 | -------------------------------------------------------------------------------- /app/templates/layout.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}{% endblock %} - Asset Lookup 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {% block css %}{% endblock %} 16 | 17 | 18 | {% if current_user.is_authenticated %} 19 | {% include 'partials/navbar.html' %} 20 | {% endif %} 21 | 22 |
23 |
24 | {% if current_user.is_authenticated %} 25 | 28 | 29 |
30 |
31 | {% include 'partials/alerts.html' %} 32 |
33 | {% block content %}{% endblock %} 34 |
35 | {% else %} 36 |
37 | {% include 'partials/alerts.html' %} 38 | {% block auth_content %}{% endblock %} 39 |
40 | {% endif %} 41 |
42 |
43 | 44 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | {% block js %}{% endblock %} 60 | 61 | 62 | -------------------------------------------------------------------------------- /app/templates/notification/acknowledge.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "layout.html" %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |
10 |
Response Recorded
11 |
12 |
13 |
14 |

Thank you!

15 |

Your response has been recorded successfully.

16 |
17 | 18 |
Asset Information
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 51 | 52 | 53 |
Asset Name{{ asset.product_name }}
Model{{ asset.product_model }}
Serial Number{{ asset.serial_number }}
Warranty Expiry Date{{ asset.warranty_expiry_date.strftime('%B %d, %Y') }}
Your Response 41 | {% if action == 'renewed' %} 42 | Warranty Has Been Renewed 43 | {% elif action == 'will_not_renew' %} 44 | Will Not Renew 45 | {% elif action == 'pending' %} 46 | Action Pending 47 | {% elif action == 'disable_notifications' %} 48 | Notifications Disabled 49 | {% endif %} 50 |
54 |
55 | 56 | {% if action == 'renewed' %} 57 |
58 |

Your warranty renewal has been recorded. Thank you for updating this information.

59 |
60 | {% elif action == 'will_not_renew' %} 61 |
62 |

You have indicated that this warranty will not be renewed. No further notifications will be sent.

63 |
64 | {% elif action == 'pending' %} 65 |
66 |

You have indicated that action is pending on this warranty. You will continue to receive notifications based on your notification preferences.

67 |
68 | {% elif action == 'disable_notifications' %} 69 |
70 |

Notifications have been disabled for this asset. You will no longer receive warranty expiration alerts for it.

71 |
72 | {% endif %} 73 | 74 | 78 |
79 |
80 |
81 |
82 |
83 | {% endblock %} 84 | -------------------------------------------------------------------------------- /app/templates/notification/acknowledge_renewal.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "layout.html" %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |
10 |
Warranty Renewal Recorded
11 |
12 |
13 |
14 |

Renewal Complete!

15 |

The warranty renewal has been recorded successfully.

16 |
17 | 18 |
Updated Asset Information
19 |
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 |
Asset Name{{ asset.product_name }}
Model{{ asset.product_model }}
Serial Number{{ asset.serial_number }}
New Warranty Duration{{ asset.warranty_duration }} months
New Warranty Expiry Date{{ asset.warranty_expiry_date.strftime('%B %d, %Y') }}
44 |
45 | 46 |
47 |

The warranty information has been updated in the system. No further expiration notifications will be sent until closer to the new expiry date.

48 |
49 | 50 | 54 |
55 |
56 |
57 |
58 |
59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /app/templates/notification/renewal_form.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "layout.html" %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
9 |
10 |
Record Warranty Renewal
11 |
12 |
13 |
14 |

Please provide the details of the warranty renewal for the following asset:

15 |
16 | 17 |
Asset Information
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 |
Asset Name{{ asset.product_name }}
Model{{ asset.product_model }}
Serial Number{{ asset.serial_number }}
Previous Warranty Expiry{{ asset.warranty_expiry_date.strftime('%B %d, %Y') }}
39 |
40 | 41 |
42 |
43 | 44 | 45 |
Enter the duration of the new warranty in months. The expiry date will be calculated from today.
46 |
47 | 48 | 49 | Cancel 50 |
51 |
52 |
53 |
54 |
55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /app/templates/partials/alerts.html: -------------------------------------------------------------------------------- 1 | {% with messages = get_flashed_messages(with_categories=true) %} 2 | {% if messages %} 3 | {% for category, message in messages %} 4 | 8 | {% endfor %} 9 | {% endif %} 10 | {% endwith %} 11 | -------------------------------------------------------------------------------- /app/templates/partials/navbar.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/templates/partials/sidebar.html: -------------------------------------------------------------------------------- 1 |
2 | 103 |
104 | -------------------------------------------------------------------------------- /app/templates/static_pages/about.html: -------------------------------------------------------------------------------- 1 | 2 | {% extends "layout.html" %} 3 | {% block content %} 4 |
5 |
6 |
7 |
8 | Asset Lookup Logo 9 |

About Asset Lookup

10 |
11 |
12 |
13 |

What is Asset Lookup?

14 |

Asset Lookup is a comprehensive asset management system designed to help organizations track and manage their assets effectively. With powerful warranty notification features, it ensures you're always aware of upcoming warranty expirations.

15 | 16 |

Key Features

17 |
    18 |
  • Asset tracking and management
  • 19 |
  • Warranty expiration notifications
  • 20 |
  • User and group-based permissions
  • 21 |
  • Customizable notification preferences
  • 22 |
  • Audit logging for compliance
  • 23 |
  • Tag-based asset organization
  • 24 |
25 | 26 |

Version

27 |

Current version: 4.0

28 |

2025.05.26

29 | 30 |

About the Developer

31 |

Asset Lookup was developed by Toks Hernandez, who created this platform to address critical needs in asset tracking and management. Driven by a desire to solve organizational challenges, Toks designed a tool to make asset management more straightforward and efficient.

32 | 33 |

Contact

34 |

For support or questions, please contact your system administrator or reach out to the developer.

35 |
36 |
37 |
38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /app/utils/__init__.py: -------------------------------------------------------------------------------- 1 | # utils/__init__.py This file makes the directory a Python package 2 | -------------------------------------------------------------------------------- /app/utils/audit.py: -------------------------------------------------------------------------------- 1 | from app import db 2 | from app.models.audit import AuditLog 3 | from flask import request, current_app 4 | from flask_login import current_user 5 | import json 6 | from datetime import datetime 7 | import traceback 8 | 9 | def log_activity(action, resource_type, resource_id=None, description=None, details=None, 10 | user=None, username=None, ip_address=None, user_agent=None, status='success'): 11 | """ 12 | Log an activity to the audit log 13 | 14 | Args: 15 | action: The action performed (CREATE, UPDATE, DELETE, etc.) 16 | resource_type: The type of resource (User, Asset, Group, etc.) 17 | resource_id: ID of the affected resource (optional) 18 | description: Human-readable description of the action (optional) 19 | details: JSON string with detailed changes (optional) 20 | user: User object (optional, defaults to current_user) 21 | username: Username string (optional, used if user is None) 22 | ip_address: IP address (optional, defaults to request.remote_addr) 23 | user_agent: User agent (optional, defaults to request.user_agent.string) 24 | status: Status of the action (success, failure, warning, etc.) 25 | """ 26 | try: 27 | # Get user information 28 | if user is None: 29 | if current_user and current_user.is_authenticated: 30 | user = current_user 31 | username = current_user.username 32 | elif username is None: 33 | username = 'System' 34 | 35 | # Get request information 36 | if ip_address is None and request: 37 | ip_address = request.remote_addr 38 | 39 | if user_agent is None and request and request.user_agent: 40 | user_agent = request.user_agent.string 41 | 42 | # Convert details to JSON if it's a dict 43 | if isinstance(details, dict): 44 | details = json.dumps(details) 45 | 46 | # Create the audit log entry 47 | log_entry = AuditLog( 48 | action=action, 49 | resource_type=resource_type, 50 | resource_id=resource_id, 51 | description=description, 52 | details=details, 53 | ip_address=ip_address, 54 | user_agent=user_agent, 55 | status=status 56 | ) 57 | 58 | # Set user information if available 59 | if user: 60 | log_entry.user = user 61 | log_entry.user_id = user.id 62 | log_entry.username = user.username 63 | elif username: 64 | log_entry.username = username 65 | 66 | db.session.add(log_entry) 67 | db.session.commit() 68 | 69 | except Exception as e: 70 | # Handle errors gracefully 71 | if current_app: 72 | current_app.logger.error(f"Error logging activity: {str(e)}") 73 | current_app.logger.error(traceback.format_exc()) 74 | db.session.rollback() 75 | 76 | def log_login(user, success=True, username=None, description=None): 77 | """Log a login attempt""" 78 | action = "LOGIN" if success else "LOGIN_FAILED" 79 | if description is None: 80 | description = f"User {'logged in successfully' if success else 'failed to log in'}" 81 | 82 | log_activity( 83 | action=action, 84 | resource_type="Authentication", 85 | user=user, 86 | username=username, 87 | description=description, 88 | status="success" if success else "failure" 89 | ) 90 | 91 | def log_logout(user): 92 | """Log a logout""" 93 | log_activity( 94 | action="LOGOUT", 95 | resource_type="Authentication", 96 | user=user, 97 | description=f"User logged out" 98 | ) 99 | 100 | def log_user_change(user, action, old_data=None, new_data=None, description=None): 101 | """Log a user change (create, update, delete)""" 102 | details = None 103 | if old_data and new_data: 104 | details = { 105 | 'old': old_data, 106 | 'new': new_data 107 | } 108 | 109 | log_activity( 110 | action=action, 111 | resource_type="User", 112 | resource_id=user.id, 113 | description=description or f"User {user.username} {action.lower()}d", 114 | details=details 115 | ) 116 | 117 | def log_asset_change(asset, action, old_data=None, new_data=None, description=None): 118 | """Log an asset change (create, update, delete)""" 119 | details = None 120 | if old_data and new_data: 121 | details = { 122 | 'old': old_data, 123 | 'new': new_data 124 | } 125 | 126 | log_activity( 127 | action=action, 128 | resource_type="Asset", 129 | resource_id=asset.id, 130 | description=description or f"Asset {asset.product_name} {action.lower()}d", 131 | details=details 132 | ) 133 | 134 | def log_group_change(group, action, old_data=None, new_data=None, description=None): 135 | """Log a group change (create, update, delete)""" 136 | details = None 137 | if old_data and new_data: 138 | details = { 139 | 'old': old_data, 140 | 'new': new_data 141 | } 142 | 143 | log_activity( 144 | action=action, 145 | resource_type="Group", 146 | resource_id=group.id, 147 | description=description or f"Group {group.name} {action.lower()}d", 148 | details=details 149 | ) 150 | 151 | def log_system_event(event_type, description, details=None): 152 | """Log a system event""" 153 | log_activity( 154 | action="SYSTEM", 155 | resource_type=event_type, 156 | description=description, 157 | details=details, 158 | username="System" 159 | ) 160 | -------------------------------------------------------------------------------- /app/utils/backup_scheduler.py: -------------------------------------------------------------------------------- 1 | # app/utils/backup_scheduler.py - Integration with existing scheduler 2 | 3 | from flask import current_app 4 | from app.models.setting import Setting 5 | from app.routes.backup import get_backup_settings, perform_backup 6 | import json 7 | import threading 8 | 9 | def setup_backup_scheduler(app, scheduler): 10 | """Setup backup scheduler integration""" 11 | try: 12 | with app.app_context(): 13 | # Get backup settings 14 | backup_settings = get_backup_settings() 15 | 16 | if backup_settings.get('auto_backup_enabled', False): 17 | update_backup_scheduler(app, scheduler, backup_settings) 18 | app.logger.info("Backup scheduler initialized successfully") 19 | else: 20 | app.logger.info("Auto backup is disabled") 21 | 22 | except Exception as e: 23 | app.logger.error(f"Error setting up backup scheduler: {str(e)}") 24 | 25 | def update_backup_scheduler(app, scheduler, settings): 26 | """Update backup scheduler with current settings""" 27 | try: 28 | # Remove existing backup job if it exists 29 | if scheduler.running: 30 | try: 31 | scheduler.remove_job('database_backup') 32 | app.logger.info("Removed existing backup job") 33 | except: 34 | pass # Job might not exist 35 | 36 | # Only add job if auto backup is enabled 37 | if not settings.get('auto_backup_enabled', False): 38 | app.logger.info("Auto backup disabled, no job scheduled") 39 | return 40 | 41 | # Get schedule settings 42 | schedule_type = settings.get('backup_schedule', 'daily') 43 | backup_time = settings.get('backup_time', '02:00') 44 | 45 | # Parse backup time 46 | try: 47 | hour, minute = map(int, backup_time.split(':')) 48 | except: 49 | hour, minute = 2, 0 # Default to 2:00 AM 50 | 51 | # Create trigger based on schedule type 52 | if schedule_type == 'hourly': 53 | trigger_kwargs = { 54 | 'trigger': 'interval', 55 | 'hours': 1, 56 | 'start_date': None 57 | } 58 | schedule_desc = "every hour" 59 | elif schedule_type == 'weekly': 60 | trigger_kwargs = { 61 | 'trigger': 'cron', 62 | 'day_of_week': 0, # Monday 63 | 'hour': hour, 64 | 'minute': minute 65 | } 66 | schedule_desc = f"weekly on Monday at {backup_time}" 67 | else: # daily 68 | trigger_kwargs = { 69 | 'trigger': 'cron', 70 | 'hour': hour, 71 | 'minute': minute 72 | } 73 | schedule_desc = f"daily at {backup_time}" 74 | 75 | # Add the backup job 76 | scheduler.add_job( 77 | func=run_scheduled_backup, 78 | id='database_backup', 79 | name=f"Database Backup ({schedule_desc})", 80 | replace_existing=True, 81 | **trigger_kwargs 82 | ) 83 | 84 | app.logger.info(f"Backup job scheduled: {schedule_desc}") 85 | 86 | except Exception as e: 87 | app.logger.error(f"Error updating backup scheduler: {str(e)}") 88 | 89 | def run_scheduled_backup(): 90 | """Standalone function to run scheduled backup with proper app context""" 91 | try: 92 | # Import the Flask app 93 | from app import create_app 94 | 95 | # Create a new app instance for this background task 96 | app = create_app() 97 | 98 | with app.app_context(): 99 | try: 100 | # Get current backup settings 101 | backup_settings = get_backup_settings() 102 | 103 | # Perform the backup 104 | app.logger.info("Starting scheduled database backup...") 105 | perform_backup(backup_settings) 106 | app.logger.info("Scheduled database backup completed successfully") 107 | 108 | except Exception as e: 109 | app.logger.error(f"Scheduled backup failed: {str(e)}") 110 | import traceback 111 | app.logger.error(traceback.format_exc()) 112 | 113 | except Exception as e: 114 | # Fallback logging to stdout if Flask logging isn't available 115 | print(f"CRITICAL ERROR in scheduled backup: {str(e)}") 116 | import traceback 117 | print(traceback.format_exc()) 118 | 119 | # Add this function to your existing app/utils/scheduler.py file 120 | def update_backup_schedule_from_settings(): 121 | """Update backup schedule when settings change""" 122 | try: 123 | from app import scheduler 124 | from flask import current_app 125 | 126 | # Get backup settings 127 | backup_settings = get_backup_settings() 128 | 129 | # Update the scheduler 130 | update_backup_scheduler(current_app, scheduler, backup_settings) 131 | 132 | except Exception as e: 133 | current_app.logger.error(f"Error updating backup schedule: {str(e)}") 134 | -------------------------------------------------------------------------------- /app/utils/config_service.py: -------------------------------------------------------------------------------- 1 | # app/utils/config_service.py - Updated to handle 2FA settings properly 2 | from app import db 3 | from app.models.setting import Setting 4 | from flask import current_app 5 | import os 6 | import json 7 | 8 | class ConfigService: 9 | @staticmethod 10 | def get_setting(key, default=None): 11 | """Get a setting value, checking multiple sources in order""" 12 | # 1. First check current app config (runtime settings) 13 | if hasattr(current_app, 'config') and key in current_app.config: 14 | return current_app.config[key] 15 | 16 | # 2. Check settings file 17 | try: 18 | settings_file = os.path.join(current_app.root_path, '..', 'instance', 'settings.json') 19 | if os.path.exists(settings_file): 20 | with open(settings_file, 'r') as f: 21 | settings = json.load(f) 22 | if key in settings: 23 | # Also update the app config for consistency 24 | current_app.config[key] = settings[key] 25 | return settings[key] 26 | except Exception as e: 27 | current_app.logger.error(f"Error reading settings file: {str(e)}") 28 | 29 | # 3. Check database settings 30 | try: 31 | setting = Setting.query.filter_by(id=key).first() 32 | if setting: 33 | value = setting.value 34 | # Try to parse JSON values 35 | try: 36 | import json 37 | parsed_value = json.loads(value) 38 | # Update app config for consistency 39 | current_app.config[key] = parsed_value 40 | return parsed_value 41 | except: 42 | # Update app config for consistency 43 | current_app.config[key] = value 44 | return value 45 | except Exception as e: 46 | current_app.logger.error(f"Error reading database setting {key}: {str(e)}") 47 | 48 | # 4. Return default 49 | return default 50 | 51 | @staticmethod 52 | def get_bool(key, default=False): 53 | """Get a boolean setting value""" 54 | value = ConfigService.get_setting(key, default) 55 | 56 | # Handle various boolean representations 57 | if isinstance(value, bool): 58 | return value 59 | elif isinstance(value, str): 60 | return value.lower() in ('true', '1', 'yes', 'on', 'enabled') 61 | elif isinstance(value, int): 62 | return bool(value) 63 | else: 64 | return bool(default) 65 | 66 | @staticmethod 67 | def get_int(key, default=0): 68 | """Get an integer setting value""" 69 | value = ConfigService.get_setting(key, default) 70 | try: 71 | return int(value) 72 | except (ValueError, TypeError): 73 | return int(default) 74 | 75 | @staticmethod 76 | def set_setting(key, value): 77 | """Set a setting value in the database""" 78 | try: 79 | setting = Setting.query.filter_by(id=key).first() 80 | if setting: 81 | setting.value = str(value) if not isinstance(value, str) else value 82 | else: 83 | setting = Setting(id=key, value=str(value) if not isinstance(value, str) else value) 84 | db.session.add(setting) 85 | 86 | # Also update the app config for immediate availability 87 | current_app.config[key] = value 88 | 89 | db.session.commit() 90 | return True 91 | except Exception as e: 92 | current_app.logger.error(f"Error setting {key}: {str(e)}") 93 | db.session.rollback() 94 | return False 95 | 96 | @staticmethod 97 | def refresh_config(): 98 | """Refresh all configuration from file and database""" 99 | try: 100 | # Load from settings file 101 | settings_file = os.path.join(current_app.root_path, '..', 'instance', 'settings.json') 102 | if os.path.exists(settings_file): 103 | with open(settings_file, 'r') as f: 104 | settings = json.load(f) 105 | for key, value in settings.items(): 106 | current_app.config[key] = value 107 | 108 | current_app.logger.info("Configuration refreshed successfully") 109 | except Exception as e: 110 | current_app.logger.error(f"Error refreshing configuration: {str(e)}") 111 | 112 | @staticmethod 113 | def is_2fa_enabled(): 114 | """Specific method to check if 2FA is enabled system-wide""" 115 | return ConfigService.get_bool('TWO_FACTOR_ENABLED', False) -------------------------------------------------------------------------------- /app/utils/oidc.py: -------------------------------------------------------------------------------- 1 | # app/utils/oidc.py - OIDC Client Module 2 | import requests 3 | from authlib.integrations.flask_client import OAuth 4 | from flask import current_app, session, url_for, request 5 | from urllib.parse import urlencode 6 | import secrets 7 | import hashlib 8 | import base64 9 | import json 10 | 11 | class OIDCClient: 12 | def __init__(self, app=None): 13 | self.oauth = OAuth() 14 | self.client = None 15 | if app: 16 | self.init_app(app) 17 | 18 | def init_app(self, app): 19 | """Initialize OIDC client with Flask app""" 20 | self.oauth.init_app(app) 21 | 22 | # Get OIDC configuration 23 | oidc_config = self._get_oidc_config() 24 | 25 | if oidc_config and oidc_config.get('enabled'): 26 | try: 27 | # Register OIDC client 28 | self.client = self.oauth.register( 29 | name='oidc', 30 | client_id=oidc_config['client_id'], 31 | client_secret=oidc_config['client_secret'], 32 | server_metadata_url=oidc_config.get('discovery_url'), 33 | client_kwargs={ 34 | 'scope': 'openid email profile', 35 | 'token_endpoint_auth_method': 'client_secret_post' 36 | } 37 | ) 38 | app.logger.info("OIDC client initialized successfully") 39 | except Exception as e: 40 | app.logger.error(f"Failed to initialize OIDC client: {str(e)}") 41 | self.client = None 42 | 43 | def _get_oidc_config(self): 44 | """Get OIDC configuration from app config or settings""" 45 | from app.utils.config_service import ConfigService 46 | 47 | return { 48 | 'enabled': ConfigService.get_bool('OIDC_ENABLED', False), 49 | 'client_id': ConfigService.get_setting('OIDC_CLIENT_ID'), 50 | 'client_secret': ConfigService.get_setting('OIDC_CLIENT_SECRET'), 51 | 'discovery_url': ConfigService.get_setting('OIDC_DISCOVERY_URL'), 52 | 'issuer': ConfigService.get_setting('OIDC_ISSUER'), 53 | 'auto_create_users': ConfigService.get_bool('OIDC_AUTO_CREATE_USERS', True), 54 | 'default_role': ConfigService.get_setting('OIDC_DEFAULT_ROLE', 'user'), 55 | 'email_claim': ConfigService.get_setting('OIDC_EMAIL_CLAIM', 'email'), 56 | 'username_claim': ConfigService.get_setting('OIDC_USERNAME_CLAIM', 'preferred_username'), 57 | 'first_name_claim': ConfigService.get_setting('OIDC_FIRST_NAME_CLAIM', 'given_name'), 58 | 'last_name_claim': ConfigService.get_setting('OIDC_LAST_NAME_CLAIM', 'family_name'), 59 | 'groups_claim': ConfigService.get_setting('OIDC_GROUPS_CLAIM', 'groups'), 60 | 'role_mapping': self._parse_role_mapping(ConfigService.get_setting('OIDC_ROLE_MAPPING', '{}')), 61 | } 62 | 63 | def _parse_role_mapping(self, mapping_str): 64 | """Parse role mapping from JSON string""" 65 | try: 66 | return json.loads(mapping_str) if mapping_str else {} 67 | except: 68 | return {} 69 | 70 | def is_enabled(self): 71 | """Check if OIDC is enabled and properly configured""" 72 | return self.client is not None 73 | 74 | def generate_auth_url(self, redirect_uri=None): 75 | """Generate authorization URL for OIDC login""" 76 | if not self.is_enabled(): 77 | return None 78 | 79 | if not redirect_uri: 80 | redirect_uri = url_for('auth.oidc_callback', _external=True) 81 | 82 | # Generate state and nonce for security 83 | state = secrets.token_urlsafe(32) 84 | nonce = secrets.token_urlsafe(32) 85 | 86 | # Store in session for verification 87 | session['oidc_state'] = state 88 | session['oidc_nonce'] = nonce 89 | 90 | return self.client.authorize_redirect( 91 | redirect_uri=redirect_uri, 92 | state=state, 93 | nonce=nonce 94 | ) 95 | 96 | def handle_callback(self, code, state): 97 | """Handle OIDC callback and extract user info""" 98 | if not self.is_enabled(): 99 | raise Exception("OIDC not enabled") 100 | 101 | # Verify state parameter 102 | if state != session.get('oidc_state'): 103 | raise Exception("Invalid state parameter") 104 | 105 | # Exchange code for token 106 | token = self.client.authorize_access_token() 107 | 108 | # Verify nonce in ID token 109 | id_token = token.get('id_token') 110 | if id_token: 111 | nonce = session.get('oidc_nonce') 112 | if not self._verify_nonce(id_token, nonce): 113 | raise Exception("Invalid nonce in ID token") 114 | 115 | # Get user info 116 | user_info = self.client.parse_id_token(token) 117 | 118 | # Clear session state 119 | session.pop('oidc_state', None) 120 | session.pop('oidc_nonce', None) 121 | 122 | return user_info 123 | 124 | def _verify_nonce(self, id_token, expected_nonce): 125 | """Verify nonce in ID token (simplified)""" 126 | try: 127 | import jwt 128 | # In production, you should properly verify the JWT signature 129 | decoded = jwt.decode(id_token, options={"verify_signature": False}) 130 | return decoded.get('nonce') == expected_nonce 131 | except: 132 | return False 133 | 134 | def extract_user_data(self, user_info): 135 | """Extract user data from OIDC user info""" 136 | config = self._get_oidc_config() 137 | 138 | return { 139 | 'email': user_info.get(config['email_claim'], ''), 140 | 'username': user_info.get(config['username_claim'], ''), 141 | 'first_name': user_info.get(config['first_name_claim'], ''), 142 | 'last_name': user_info.get(config['last_name_claim'], ''), 143 | 'groups': user_info.get(config['groups_claim'], []), 144 | 'raw_user_info': user_info 145 | } 146 | 147 | def determine_user_role(self, user_data): 148 | """Determine user role based on OIDC groups and role mapping""" 149 | config = self._get_oidc_config() 150 | role_mapping = config.get('role_mapping', {}) 151 | user_groups = user_data.get('groups', []) 152 | 153 | # Check role mapping 154 | for group in user_groups: 155 | if group in role_mapping: 156 | role = role_mapping[group] 157 | return { 158 | 'is_admin': role == 'admin', 159 | 'is_group_admin': role == 'group_admin', 160 | 'role': role 161 | } 162 | 163 | # Default role 164 | default_role = config.get('default_role', 'user') 165 | return { 166 | 'is_admin': default_role == 'admin', 167 | 'is_group_admin': default_role == 'group_admin', 168 | 'role': default_role 169 | } 170 | 171 | # Initialize OIDC client 172 | oidc_client = OIDCClient() 173 | -------------------------------------------------------------------------------- /app/utils/time_utils.py: -------------------------------------------------------------------------------- 1 | # app/utils/time_utils.py 2 | import pytz 3 | from datetime import datetime, timezone, timedelta 4 | from flask import current_app 5 | 6 | def get_app_timezone(): 7 | """Get the application timezone from configuration""" 8 | tz_name = current_app.config.get('APP_TIMEZONE', 'UTC') 9 | try: 10 | return pytz.timezone(tz_name) 11 | except pytz.exceptions.UnknownTimeZoneError: 12 | current_app.logger.error(f"Unknown timezone: {tz_name}, falling back to UTC") 13 | return pytz.UTC 14 | 15 | def utcnow(): 16 | """Get current UTC time with timezone info""" 17 | return datetime.now(timezone.utc) 18 | 19 | def localnow(): 20 | """Get current time in application timezone""" 21 | app_tz = get_app_timezone() 22 | return datetime.now(timezone.utc).astimezone(app_tz) 23 | 24 | def utc_to_local(utc_dt): 25 | """Convert UTC datetime to local datetime using app timezone""" 26 | if utc_dt is None: 27 | return None 28 | 29 | # Add timezone info if it's naive 30 | if utc_dt.tzinfo is None: 31 | utc_dt = utc_dt.replace(tzinfo=timezone.utc) 32 | 33 | # Convert to app timezone 34 | app_tz = get_app_timezone() 35 | return utc_dt.astimezone(app_tz) 36 | 37 | def local_to_utc(local_dt): 38 | """Convert local datetime to UTC datetime""" 39 | if local_dt is None: 40 | return None 41 | 42 | # If it has no timezone, assume it's in app timezone 43 | if local_dt.tzinfo is None: 44 | app_tz = get_app_timezone() 45 | local_dt = app_tz.localize(local_dt) 46 | 47 | # Convert to UTC 48 | return local_dt.astimezone(timezone.utc) 49 | 50 | def format_datetime(dt, format_str=None): 51 | """Format datetime according to application settings""" 52 | if dt is None: 53 | return "" 54 | 55 | # Ensure datetime has timezone info 56 | if dt.tzinfo is None: 57 | dt = dt.replace(tzinfo=timezone.utc) 58 | 59 | # Convert to local time 60 | local_dt = utc_to_local(dt) 61 | 62 | # Use default format if none provided 63 | if format_str is None: 64 | format_str = '%Y-%m-%d %H:%M:%S' 65 | 66 | return local_dt.strftime(format_str) 67 | 68 | def parse_datetime(date_str, format_str=None): 69 | """Parse datetime string to UTC datetime""" 70 | if not date_str: 71 | return None 72 | 73 | # Use default format if none provided 74 | if format_str is None: 75 | format_str = '%Y-%m-%d %H:%M:%S' 76 | 77 | # Parse as naive datetime 78 | dt = datetime.strptime(date_str, format_str) 79 | 80 | # Localize to app timezone then convert to UTC 81 | app_tz = get_app_timezone() 82 | local_dt = app_tz.localize(dt) 83 | return local_dt.astimezone(timezone.utc) 84 | 85 | def today(): 86 | """Get today's date in application timezone""" 87 | return localnow().date() 88 | 89 | def get_expiry_threshold(days=30): 90 | """Calculate expiry threshold date (today + days)""" 91 | return (today() + timedelta(days=days)) -------------------------------------------------------------------------------- /app/utils/timezone_util.py: -------------------------------------------------------------------------------- 1 | # utils/timezone_util.py 2 | import pytz 3 | from datetime import datetime 4 | from flask import current_app, g, request 5 | from functools import wraps 6 | 7 | def get_app_timezone(): 8 | """Get the application timezone configured in app.config""" 9 | app_tz_name = current_app.config.get('APP_TIMEZONE', 'UTC') 10 | try: 11 | return pytz.timezone(app_tz_name) 12 | except pytz.exceptions.UnknownTimeZoneError: 13 | current_app.logger.warning(f"Unknown timezone: {app_tz_name}, falling back to UTC") 14 | return pytz.UTC 15 | 16 | def utc_to_local(utc_dt): 17 | """Convert UTC datetime to local timezone configured in the application""" 18 | if utc_dt is None: 19 | return None 20 | 21 | if utc_dt.tzinfo is None: 22 | # Assume naive datetimes are UTC 23 | utc_dt = pytz.UTC.localize(utc_dt) 24 | 25 | local_tz = get_app_timezone() 26 | return utc_dt.astimezone(local_tz) 27 | 28 | def local_to_utc(local_dt): 29 | """Convert local datetime to UTC for storage""" 30 | if local_dt is None: 31 | return None 32 | 33 | local_tz = get_app_timezone() 34 | 35 | if local_dt.tzinfo is None: 36 | # Assume naive datetimes are in app's local timezone 37 | local_dt = local_tz.localize(local_dt) 38 | 39 | return local_dt.astimezone(pytz.UTC) 40 | 41 | def local_now(): 42 | """Get current time in application timezone""" 43 | utc_now = datetime.now(pytz.UTC) 44 | return utc_to_local(utc_now) 45 | 46 | def format_datetime(dt, format_string=None): 47 | """Format datetime according to application settings""" 48 | if dt is None: 49 | return "" 50 | 51 | # Convert to local timezone if it's a UTC time 52 | if dt.tzinfo is None or dt.tzinfo == pytz.UTC: 53 | dt = utc_to_local(dt) 54 | 55 | # Use default format if none provided 56 | if format_string is None: 57 | format_string = '%Y-%m-%d %H:%M:%S' 58 | 59 | return dt.strftime(format_string) 60 | 61 | def timezone_middleware(): 62 | """Flask middleware to handle timezone conversion in request context""" 63 | def decorator(f): 64 | @wraps(f) 65 | def wrapped(*args, **kwargs): 66 | # Store the application timezone in the request context 67 | g.timezone = get_app_timezone() 68 | return f(*args, **kwargs) 69 | return wrapped 70 | return decorator 71 | -------------------------------------------------------------------------------- /backups/default_Backup_directory: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/backups/default_Backup_directory -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: . 4 | image: asset-lookup:latest 5 | container_name: asset-lookup-web2 6 | ports: 7 | - "3443:5000" 8 | volumes: 9 | - ./app:/app/app 10 | - ./uploads:/app/app/static/uploads 11 | - ./logs:/app/logs 12 | - ./instance:/app/instance # Ensure this line exists 13 | - ./backups:/app/backups 14 | environment: 15 | - FLASK_APP=run.py 16 | - DATABASE_URL=sqlite:////app/instance/asset_lookup.db 17 | - SECRET_KEY=change_this_for_production 18 | - SERVER_HOST=0.0.0.0 19 | - SERVER_PORT=5000 20 | - EXTERNAL_URL=htts://yourwebsitewherethisserverishosted.com # Add this line for proper URL generation in emails 21 | restart: unless-stopped 22 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | echo "Starting Asset Lookup application..." 5 | 6 | # CRITICAL: Ensure instance directory exists and is writable 7 | echo "Creating required directories..." 8 | mkdir -p /app/instance 9 | mkdir -p /app/logs 10 | mkdir -p /app/app/static/uploads 11 | 12 | # Set permissions to ensure write access 13 | chmod 755 /app/instance 14 | chmod 755 /app/logs 15 | chmod 755 /app/app/static/uploads 16 | 17 | # Test that we can write to the instance directory 18 | echo "Testing database directory access..." 19 | if touch /app/instance/test_write.tmp 2>/dev/null; then 20 | rm -f /app/instance/test_write.tmp 21 | echo "✅ Database directory is writable" 22 | else 23 | echo "❌ Cannot write to /app/instance directory" 24 | ls -la /app/ 25 | ls -la /app/instance/ 26 | exit 1 27 | fi 28 | 29 | # Create symbolic link for email templates 30 | echo "Setting up email templates..." 31 | mkdir -p /app/app/templates/emails 32 | if [ -d "/app/app/templates/email" ]; then 33 | ln -sf /app/app/templates/email/* /app/app/templates/emails/ 2>/dev/null || true 34 | fi 35 | 36 | echo "Starting application with gunicorn..." 37 | exec gunicorn --bind 0.0.0.0:5000 run:app -------------------------------------------------------------------------------- /instance/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/instance/.gitkeep -------------------------------------------------------------------------------- /instance/default_database_directory: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/instance/default_database_directory -------------------------------------------------------------------------------- /logs/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/logs/.gitkeep -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask==2.0.1 2 | Flask-Cors==3.0.10 3 | Flask-Login==0.6.2 4 | Flask-Bcrypt==1.0.1 5 | Flask-SQLAlchemy==2.5.1 6 | SQLAlchemy==1.4.23 7 | gunicorn==20.1.0 8 | # psycopg2-binary can be problematic on some systems 9 | # if it fails, try installing libpq-dev and using regular psycopg2 10 | # sudo apt-get install libpq-dev 11 | # or comment this out if you're using SQLite 12 | psycopg2-binary==2.9.3 13 | Werkzeug==2.0.1 14 | PyJWT==2.6.0 15 | email-validator==1.3.1 16 | APScheduler==3.10.4 17 | python-dateutil==2.8.2 18 | Flask-Mail==0.9.1 19 | # 2FA Dependencies 20 | pyotp==2.8.0 21 | qrcode[pil]==7.4.2 22 | requests>=2.28.0 23 | authlib>=1.2.0 24 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from app import create_app 2 | 3 | app = create_app() 4 | 5 | if __name__ == '__main__': 6 | # Ensure the app binds to all network interfaces on port 3443 7 | app.run(host='0.0.0.0', port=3443, debug=False) 8 | -------------------------------------------------------------------------------- /screenshots/Admin dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Admin dashboard.png -------------------------------------------------------------------------------- /screenshots/Asset Dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Asset Dashboard.png -------------------------------------------------------------------------------- /screenshots/Asset List.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Asset List.png -------------------------------------------------------------------------------- /screenshots/Audit Logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Audit Logs.png -------------------------------------------------------------------------------- /screenshots/Database Backup Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Database Backup Settings.png -------------------------------------------------------------------------------- /screenshots/Email Notification.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Email Notification.png -------------------------------------------------------------------------------- /screenshots/Email Reponse2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Email Reponse2.png -------------------------------------------------------------------------------- /screenshots/Email Response1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Email Response1.png -------------------------------------------------------------------------------- /screenshots/Email Response3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Email Response3.png -------------------------------------------------------------------------------- /screenshots/Email Response4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Email Response4.png -------------------------------------------------------------------------------- /screenshots/Email SMTP Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Email SMTP Settings.png -------------------------------------------------------------------------------- /screenshots/Group Management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Group Management.png -------------------------------------------------------------------------------- /screenshots/Notification Settings 1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Notification Settings 1.png -------------------------------------------------------------------------------- /screenshots/Notification Settings 2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Notification Settings 2.png -------------------------------------------------------------------------------- /screenshots/SSO Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/SSO Settings.png -------------------------------------------------------------------------------- /screenshots/System Settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/System Settings.png -------------------------------------------------------------------------------- /screenshots/Tags Management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/Tags Management.png -------------------------------------------------------------------------------- /screenshots/User Management.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/screenshots/User Management.png -------------------------------------------------------------------------------- /uploads/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thokzz/asset-lookup/4fbe4e73ee6483ddf9b1b069ecdcee5d53f0b550/uploads/.gitkeep --------------------------------------------------------------------------------