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 |
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 |
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 |
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.
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
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
--------------------------------------------------------------------------------