diff --git a/app.py b/app.py index dff4740..d9e6cd2 100644 --- a/app.py +++ b/app.py @@ -20,7 +20,7 @@ logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) app = Flask(__name__) -app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db' +app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///data/timetrack.db' app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'dev_key_for_timetrack') app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session lasts for 7 days @@ -101,11 +101,11 @@ def role_required(min_role): def decorated_function(*args, **kwargs): if g.user is None: return redirect(url_for('login', next=request.url)) - + # Admin always has access if g.user.is_admin: return f(*args, **kwargs) - + # Check role hierarchy role_hierarchy = { Role.TEAM_MEMBER: 1, @@ -113,11 +113,11 @@ def role_required(min_role): Role.SUPERVISOR: 3, Role.ADMIN: 4 } - + if role_hierarchy.get(g.user.role, 0) < role_hierarchy.get(min_role, 0): flash('You do not have sufficient permissions to access this page.', 'error') return redirect(url_for('home')) - + return f(*args, **kwargs) return decorated_function return role_decorator @@ -141,10 +141,10 @@ def home(): if g.user: # Get active entry (no departure time) active_entry = TimeEntry.query.filter_by( - user_id=g.user.id, + user_id=g.user.id, departure_time=None ).first() - + # Get today's completed entries for history today = datetime.now().date() history = TimeEntry.query.filter( @@ -153,16 +153,16 @@ def home(): TimeEntry.arrival_time >= datetime.combine(today, time.min), TimeEntry.arrival_time <= datetime.combine(today, time.max) ).order_by(TimeEntry.arrival_time.desc()).all() - + # Get available projects for this user available_projects = [] all_projects = Project.query.filter_by(is_active=True).all() for project in all_projects: if project.is_user_allowed(g.user): available_projects.append(project) - - return render_template('index.html', title='Home', - active_entry=active_entry, + + return render_template('index.html', title='Home', + active_entry=active_entry, history=history, available_projects=available_projects) else: @@ -173,9 +173,9 @@ def login(): if request.method == 'POST': username = request.form.get('username') password = request.form.get('password') - + user = User.query.filter_by(username=username).first() - + if user: # Fix role if it's a string or None if isinstance(user.role, str) or user.role is None: @@ -190,21 +190,21 @@ def login(): 'Administrator': Role.ADMIN, 'ADMIN': Role.ADMIN } - + if isinstance(user.role, str): user.role = role_mapping.get(user.role, Role.TEAM_MEMBER) else: user.role = Role.ADMIN if user.is_admin else Role.TEAM_MEMBER - + db.session.commit() - + # Now proceed with password check if user.check_password(password): # Check if user is blocked if user.is_blocked: flash('Your account has been disabled. Please contact an administrator.', 'error') return render_template('login.html') - + # Check if 2FA is enabled if user.two_factor_enabled: # Store user ID for 2FA verification @@ -215,12 +215,12 @@ def login(): session['user_id'] = user.id session['username'] = user.username session['is_admin'] = user.is_admin - + flash('Login successful!', 'success') return redirect(url_for('home')) - + flash('Invalid username or password', 'error') - + return render_template('login.html', title='Login') @app.route('/logout') @@ -234,17 +234,17 @@ def register(): # Check if registration is enabled reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first() registration_enabled = reg_setting and reg_setting.value == 'true' - + if not registration_enabled: flash('Registration is currently disabled by the administrator.', 'error') return redirect(url_for('login')) - + if request.method == 'POST': username = request.form.get('username') email = request.form.get('email') password = request.form.get('password') confirm_password = request.form.get('confirm_password') - + # Validate input error = None if not username: @@ -259,27 +259,27 @@ def register(): error = 'Username already exists' elif User.query.filter_by(email=email).first(): error = 'Email already registered' - + if error is None: try: # Check if this is the first user account is_first_user = User.query.count() == 0 - + new_user = User(username=username, email=email, is_verified=False) new_user.set_password(password) - + # Make first user an admin with full privileges if is_first_user: new_user.is_admin = True new_user.role = Role.ADMIN new_user.is_verified = True # Auto-verify first user - + # Generate verification token token = new_user.generate_verification_token() - + db.session.add(new_user) db.session.commit() - + if is_first_user: # First user gets admin privileges and is auto-verified logger.info(f"First user account created: {username} with admin privileges") @@ -304,31 +304,31 @@ The TimeTrack Team mail.send(msg) logger.info(f"Verification email sent to {email}") flash('Registration initiated! Please check your email to verify your account.', 'success') - + return redirect(url_for('login')) except Exception as e: db.session.rollback() logger.error(f"Error during registration: {str(e)}") error = f"An error occurred during registration: {str(e)}" - + flash(error, 'error') - + return render_template('register.html', title='Register') @app.route('/verify_email/') def verify_email(token): user = User.query.filter_by(verification_token=token).first() - + if not user: flash('Invalid or expired verification link.', 'error') return redirect(url_for('login')) - + if user.verify_token(token): db.session.commit() flash('Email verified successfully! You can now log in.', 'success') else: flash('Invalid or expired verification link.', 'error') - + return redirect(url_for('login')) @app.route('/dashboard') @@ -336,7 +336,7 @@ def verify_email(token): def dashboard(): # Get dashboard data based on user role dashboard_data = {} - + if g.user.is_admin or g.user.role == Role.ADMIN: # Admin sees everything dashboard_data.update({ @@ -346,7 +346,7 @@ def dashboard(): 'unverified_users': User.query.filter_by(is_verified=False).count(), 'recent_registrations': User.query.order_by(User.id.desc()).limit(5).all() }) - + if g.user.role in [Role.TEAM_LEADER, Role.SUPERVISOR] or g.user.is_admin: # Team leaders and supervisors see team-related data if g.user.team_id or g.user.is_admin: @@ -358,13 +358,13 @@ def dashboard(): # Team leaders/supervisors see their own team teams = [Team.query.get(g.user.team_id)] if g.user.team_id else [] team_members = User.query.filter_by(team_id=g.user.team_id).all() if g.user.team_id else [] - + dashboard_data.update({ 'teams': teams, 'team_members': team_members, 'team_member_count': len(team_members) }) - + # Get recent time entries for the user's oversight if g.user.is_admin: # Admin sees all recent entries @@ -375,9 +375,9 @@ def dashboard(): recent_entries = TimeEntry.query.filter(TimeEntry.user_id.in_(team_user_ids)).order_by(TimeEntry.arrival_time.desc()).limit(10).all() else: recent_entries = [] - + dashboard_data['recent_entries'] = recent_entries - + return render_template('dashboard.html', title='Dashboard', **dashboard_data) # Redirect old admin dashboard URL to new dashboard @@ -397,11 +397,11 @@ def create_user(): password = request.form.get('password') is_admin = 'is_admin' in request.form auto_verify = 'auto_verify' in request.form - + # Get role and team role_name = request.form.get('role') team_id = request.form.get('team_id') - + # Validate input error = None if not username: @@ -414,25 +414,25 @@ def create_user(): error = 'Username already exists' elif User.query.filter_by(email=email).first(): error = 'Email already registered' - + if error is None: # Convert role string to enum try: role = Role[role_name] if role_name else Role.TEAM_MEMBER except KeyError: role = Role.TEAM_MEMBER - + # Create new user with role and team new_user = User( - username=username, - email=email, - is_admin=is_admin, + username=username, + email=email, + is_admin=is_admin, is_verified=auto_verify, role=role, team_id=team_id if team_id else None ) new_user.set_password(password) - + if not auto_verify: # Generate verification token and send email token = new_user.generate_verification_token() @@ -450,39 +450,39 @@ Best regards, The TimeTrack Team ''' mail.send(msg) - + db.session.add(new_user) db.session.commit() - + if auto_verify: flash(f'User {username} created and automatically verified!', 'success') else: flash(f'User {username} created! Verification email sent.', 'success') return redirect(url_for('admin_users')) - + flash(error, 'error') - + # Get all teams for the form teams = Team.query.all() roles = [role for role in Role] - + return render_template('create_user.html', title='Create User', teams=teams, roles=roles) @app.route('/admin/users/edit/', methods=['GET', 'POST']) @admin_required def edit_user(user_id): user = User.query.get_or_404(user_id) - + if request.method == 'POST': username = request.form.get('username') email = request.form.get('email') password = request.form.get('password') is_admin = 'is_admin' in request.form - + # Get role and team role_name = request.form.get('role') team_id = request.form.get('team_id') - + # Validate input error = None if not username: @@ -493,50 +493,50 @@ def edit_user(user_id): error = 'Username already exists' elif email != user.email and User.query.filter_by(email=email).first(): error = 'Email already registered' - + if error is None: user.username = username user.email = email user.is_admin = is_admin - + # Convert role string to enum try: user.role = Role[role_name] if role_name else Role.TEAM_MEMBER except KeyError: user.role = Role.TEAM_MEMBER - + user.team_id = team_id if team_id else None - + if password: user.set_password(password) - + db.session.commit() - + flash(f'User {username} updated successfully!', 'success') return redirect(url_for('admin_users')) - + flash(error, 'error') - + # Get all teams for the form teams = Team.query.all() roles = [role for role in Role] - + return render_template('edit_user.html', title='Edit User', user=user, teams=teams, roles=roles) @app.route('/admin/users/delete/', methods=['POST']) @admin_required def delete_user(user_id): user = User.query.get_or_404(user_id) - + # Prevent deleting yourself if user.id == session.get('user_id'): flash('You cannot delete your own account', 'error') return redirect(url_for('admin_users')) - + username = user.username db.session.delete(user) db.session.commit() - + flash(f'User {username} deleted successfully', 'success') return redirect(url_for('admin_users')) @@ -544,20 +544,20 @@ def delete_user(user_id): @login_required def profile(): user = User.query.get(session['user_id']) - + if request.method == 'POST': email = request.form.get('email') current_password = request.form.get('current_password') new_password = request.form.get('new_password') confirm_password = request.form.get('confirm_password') - + # Validate input error = None if not email: error = 'Email is required' elif email != user.email and User.query.filter_by(email=email).first(): error = 'Email already registered' - + # Password change validation if new_password: if not current_password: @@ -566,19 +566,19 @@ def profile(): error = 'Current password is incorrect' elif new_password != confirm_password: error = 'New passwords do not match' - + if error is None: user.email = email - + if new_password: user.set_password(new_password) - + db.session.commit() flash('Profile updated successfully!', 'success') return redirect(url_for('profile')) - + flash(error, 'error') - + return render_template('profile.html', title='My Profile', user=user) @app.route('/2fa/setup', methods=['GET', 'POST']) @@ -587,11 +587,11 @@ def setup_2fa(): if request.method == 'POST': # Verify the TOTP code before enabling 2FA totp_code = request.form.get('totp_code') - + if not totp_code: flash('Please enter the verification code from your authenticator app.', 'error') return redirect(url_for('setup_2fa')) - + try: if g.user.verify_2fa_token(totp_code, allow_setup=True): g.user.two_factor_enabled = True @@ -605,35 +605,35 @@ def setup_2fa(): logger.error(f"2FA setup error: {str(e)}") flash('An error occurred during 2FA setup. Please try again.', 'error') return redirect(url_for('setup_2fa')) - + # GET request - show setup page if g.user.two_factor_enabled: flash('Two-factor authentication is already enabled.', 'info') return redirect(url_for('profile')) - + # Generate secret if not exists if not g.user.two_factor_secret: g.user.generate_2fa_secret() db.session.commit() - + # Generate QR code import qrcode import io import base64 - + qr_uri = g.user.get_2fa_uri() qr = qrcode.QRCode(version=1, box_size=10, border=5) qr.add_data(qr_uri) qr.make(fit=True) - + # Create QR code image qr_img = qr.make_image(fill_color="black", back_color="white") img_buffer = io.BytesIO() qr_img.save(img_buffer, format='PNG') img_buffer.seek(0) qr_code_b64 = base64.b64encode(img_buffer.getvalue()).decode() - - return render_template('setup_2fa.html', + + return render_template('setup_2fa.html', title='Setup Two-Factor Authentication', secret=g.user.two_factor_secret, qr_code=qr_code_b64) @@ -642,15 +642,15 @@ def setup_2fa(): @login_required def disable_2fa(): password = request.form.get('password') - + if not password or not g.user.check_password(password): flash('Please enter your correct password to disable 2FA.', 'error') return redirect(url_for('profile')) - + g.user.two_factor_enabled = False g.user.two_factor_secret = None db.session.commit() - + flash('Two-factor authentication has been disabled.', 'success') return redirect(url_for('profile')) @@ -660,27 +660,27 @@ def verify_2fa(): user_id = session.get('2fa_user_id') if not user_id: return redirect(url_for('login')) - + user = User.query.get(user_id) if not user or not user.two_factor_enabled: session.pop('2fa_user_id', None) return redirect(url_for('login')) - + if request.method == 'POST': totp_code = request.form.get('totp_code') - + if user.verify_2fa_token(totp_code): # Complete login process session.pop('2fa_user_id', None) session['user_id'] = user.id session['username'] = user.username session['is_admin'] = user.is_admin - + flash('Login successful!', 'success') return redirect(url_for('home')) else: flash('Invalid verification code. Please try again.', 'error') - + return render_template('verify_2fa.html', title='Two-Factor Authentication') @app.route('/about') @@ -706,16 +706,16 @@ def arrive(): # Get project and notes from request project_id = request.json.get('project_id') if request.json else None notes = request.json.get('notes') if request.json else None - + # Validate project access if project is specified if project_id: project = Project.query.get(project_id) if not project or not project.is_user_allowed(g.user): return jsonify({'error': 'Invalid or unauthorized project'}), 403 - + # Create a new time entry with arrival time for the current user new_entry = TimeEntry( - user_id=g.user.id, + user_id=g.user.id, arrival_time=datetime.now(), project_id=int(project_id) if project_id else None, notes=notes @@ -838,11 +838,11 @@ def create_tables(): # Check if we need to add new columns from sqlalchemy import inspect inspector = inspect(db.engine) - + # Check if user table exists if 'user' in inspector.get_table_names(): columns = [column['name'] for column in inspector.get_columns('user')] - + # Check for verification columns if 'is_verified' not in columns or 'verification_token' not in columns or 'token_expiry' not in columns: logger.warning("Database schema is outdated. Please run migrate_db.py to update it.") @@ -902,19 +902,19 @@ def update_entry(entry_id): def team_hours(): # Get the current user's team team = Team.query.get(g.user.team_id) - + if not team: flash('You are not assigned to any team.', 'error') return redirect(url_for('home')) - + # Get date range from query parameters or use current week as default today = datetime.now().date() start_of_week = today - timedelta(days=today.weekday()) end_of_week = start_of_week + timedelta(days=6) - + start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d')) end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d')) - + try: start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() @@ -922,14 +922,14 @@ def team_hours(): flash('Invalid date format. Using current week instead.', 'warning') start_date = start_of_week end_date = end_of_week - + # Generate a list of dates in the range for the table header date_range = [] current_date = start_date while current_date <= end_date: date_range.append(current_date) current_date += timedelta(days=1) - + return render_template( 'team_hours.html', title=f'Team Hours', @@ -943,10 +943,10 @@ def team_hours(): def history(): # Get project filter from query parameters project_filter = request.args.get('project_id') - + # Base query for user's time entries query = TimeEntry.query.filter_by(user_id=g.user.id) - + # Apply project filter if specified if project_filter: if project_filter == 'none': @@ -960,10 +960,10 @@ def history(): except ValueError: # Invalid project ID, ignore filter pass - + # Get filtered entries ordered by most recent first all_entries = query.order_by(TimeEntry.arrival_time.desc()).all() - + # Get available projects for the filter dropdown available_projects = [] all_projects = Project.query.filter_by(is_active=True).all() @@ -971,7 +971,7 @@ def history(): if project.is_user_allowed(g.user): available_projects.append(project) - return render_template('history.html', title='Time Entry History', + return render_template('history.html', title='Time Entry History', entries=all_entries, available_projects=available_projects) def calculate_work_duration(arrival_time, departure_time, total_break_duration): @@ -1067,21 +1067,21 @@ def test(): @admin_required def toggle_user_status(user_id): user = User.query.get_or_404(user_id) - + # Prevent blocking yourself if user.id == session.get('user_id'): flash('You cannot block your own account', 'error') return redirect(url_for('admin_users')) - + # Toggle the blocked status user.is_blocked = not user.is_blocked db.session.commit() - + if user.is_blocked: flash(f'User {user.username} has been blocked', 'success') else: flash(f'User {user.username} has been unblocked', 'success') - + return redirect(url_for('admin_users')) # Add this route to manage system settings @@ -1091,19 +1091,19 @@ def admin_settings(): if request.method == 'POST': # Update registration setting registration_enabled = 'registration_enabled' in request.form - + reg_setting = SystemSettings.query.filter_by(key='registration_enabled').first() if reg_setting: reg_setting.value = 'true' if registration_enabled else 'false' db.session.commit() flash('System settings updated successfully!', 'success') - + # Get current settings settings = {} for setting in SystemSettings.query.all(): if setting.key == 'registration_enabled': settings['registration_enabled'] = setting.value == 'true' - + return render_template('admin_settings.html', title='System Settings', settings=settings) # Add these routes for team management @@ -1119,68 +1119,68 @@ def create_team(): if request.method == 'POST': name = request.form.get('name') description = request.form.get('description') - + # Validate input error = None if not name: error = 'Team name is required' elif Team.query.filter_by(name=name).first(): error = 'Team name already exists' - + if error is None: new_team = Team(name=name, description=description) db.session.add(new_team) db.session.commit() - + flash(f'Team "{name}" created successfully!', 'success') return redirect(url_for('admin_teams')) - + flash(error, 'error') - + return render_template('create_team.html', title='Create Team') @app.route('/admin/teams/edit/', methods=['GET', 'POST']) @admin_required def edit_team(team_id): team = Team.query.get_or_404(team_id) - + if request.method == 'POST': name = request.form.get('name') description = request.form.get('description') - + # Validate input error = None if not name: error = 'Team name is required' elif name != team.name and Team.query.filter_by(name=name).first(): error = 'Team name already exists' - + if error is None: team.name = name team.description = description db.session.commit() - + flash(f'Team "{name}" updated successfully!', 'success') return redirect(url_for('admin_teams')) - + flash(error, 'error') - + return render_template('edit_team.html', title='Edit Team', team=team) @app.route('/admin/teams/delete/', methods=['POST']) @admin_required def delete_team(team_id): team = Team.query.get_or_404(team_id) - + # Check if team has members if team.users: flash('Cannot delete team with members. Remove all members first.', 'error') return redirect(url_for('admin_teams')) - + team_name = team.name db.session.delete(team) db.session.commit() - + flash(f'Team "{team_name}" deleted successfully!', 'success') return redirect(url_for('admin_teams')) @@ -1188,22 +1188,22 @@ def delete_team(team_id): @admin_required def manage_team(team_id): team = Team.query.get_or_404(team_id) - + if request.method == 'POST': action = request.form.get('action') - + if action == 'update_team': # Update team details name = request.form.get('name') description = request.form.get('description') - + # Validate input error = None if not name: error = 'Team name is required' elif name != team.name and Team.query.filter_by(name=name).first(): error = 'Team name already exists' - + if error is None: team.name = name team.description = description @@ -1211,7 +1211,7 @@ def manage_team(team_id): flash(f'Team "{name}" updated successfully!', 'success') else: flash(error, 'error') - + elif action == 'add_member': # Add user to team user_id = request.form.get('user_id') @@ -1225,7 +1225,7 @@ def manage_team(team_id): flash('User not found', 'error') else: flash('No user selected', 'error') - + elif action == 'remove_member': # Remove user from team user_id = request.form.get('user_id') @@ -1239,19 +1239,19 @@ def manage_team(team_id): flash('User not found or not in this team', 'error') else: flash('No user selected', 'error') - + # Get team members team_members = User.query.filter_by(team_id=team.id).all() - + # Get users not in this team for the add member form available_users = User.query.filter( (User.team_id != team.id) | (User.team_id == None) ).all() - + return render_template( - 'manage_team.html', - title=f'Manage Team: {team.name}', - team=team, + 'manage_team.html', + title=f'Manage Team: {team.name}', + team=team, team_members=team_members, available_users=available_users ) @@ -1273,7 +1273,7 @@ def create_project(): team_id = request.form.get('team_id') or None start_date_str = request.form.get('start_date') end_date_str = request.form.get('end_date') - + # Validate input error = None if not name: @@ -1282,7 +1282,7 @@ def create_project(): error = 'Project code is required' elif Project.query.filter_by(code=code).first(): error = 'Project code already exists' - + # Parse dates start_date = None end_date = None @@ -1291,16 +1291,16 @@ def create_project(): start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() except ValueError: error = 'Invalid start date format' - + if end_date_str: try: end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() except ValueError: error = 'Invalid end date format' - + if start_date and end_date and start_date > end_date: error = 'Start date cannot be after end date' - + if error is None: project = Project( name=name, @@ -1317,7 +1317,7 @@ def create_project(): return redirect(url_for('admin_projects')) else: flash(error, 'error') - + # Get available teams for the form teams = Team.query.order_by(Team.name).all() return render_template('create_project.html', title='Create Project', teams=teams) @@ -1326,7 +1326,7 @@ def create_project(): @role_required(Role.SUPERVISOR) def edit_project(project_id): project = Project.query.get_or_404(project_id) - + if request.method == 'POST': name = request.form.get('name') description = request.form.get('description') @@ -1335,7 +1335,7 @@ def edit_project(project_id): is_active = request.form.get('is_active') == 'on' start_date_str = request.form.get('start_date') end_date_str = request.form.get('end_date') - + # Validate input error = None if not name: @@ -1344,7 +1344,7 @@ def edit_project(project_id): error = 'Project code is required' elif code != project.code and Project.query.filter_by(code=code).first(): error = 'Project code already exists' - + # Parse dates start_date = None end_date = None @@ -1353,16 +1353,16 @@ def edit_project(project_id): start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() except ValueError: error = 'Invalid start date format' - + if end_date_str: try: end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() except ValueError: error = 'Invalid end date format' - + if start_date and end_date and start_date > end_date: error = 'Start date cannot be after end date' - + if error is None: project.name = name project.description = description @@ -1376,7 +1376,7 @@ def edit_project(project_id): return redirect(url_for('admin_projects')) else: flash(error, 'error') - + # Get available teams for the form teams = Team.query.order_by(Team.name).all() return render_template('edit_project.html', title='Edit Project', project=project, teams=teams) @@ -1385,10 +1385,10 @@ def edit_project(project_id): @role_required(Role.ADMIN) # Only admins can delete projects def delete_project(project_id): project = Project.query.get_or_404(project_id) - + # Check if there are time entries associated with this project time_entries_count = TimeEntry.query.filter_by(project_id=project_id).count() - + if time_entries_count > 0: flash(f'Cannot delete project "{project.name}" - it has {time_entries_count} time entries associated with it. Deactivate the project instead.', 'error') else: @@ -1396,7 +1396,7 @@ def delete_project(project_id): db.session.delete(project) db.session.commit() flash(f'Project "{project_name}" deleted successfully!', 'success') - + return redirect(url_for('admin_projects')) @app.route('/api/team/hours_data', methods=['GET']) @@ -1405,22 +1405,22 @@ def delete_project(project_id): def team_hours_data(): # Get the current user's team team = Team.query.get(g.user.team_id) - + if not team: return jsonify({ 'success': False, 'message': 'You are not assigned to any team.' }), 400 - + # Get date range from query parameters or use current week as default today = datetime.now().date() start_of_week = today - timedelta(days=today.weekday()) end_of_week = start_of_week + timedelta(days=6) - + start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d')) end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d')) include_self = request.args.get('include_self', 'false') == 'true' - + try: start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() @@ -1429,46 +1429,46 @@ def team_hours_data(): 'success': False, 'message': 'Invalid date format.' }), 400 - + # Get all team members team_members = User.query.filter_by(team_id=team.id).all() - + # Prepare data structure for team members' hours team_data = [] - + for member in team_members: # Skip if the member is the current user (team leader) and include_self is False if member.id == g.user.id and not include_self: continue - + # Get time entries for this member in the date range entries = TimeEntry.query.filter( TimeEntry.user_id == member.id, TimeEntry.arrival_time >= datetime.combine(start_date, time.min), TimeEntry.arrival_time <= datetime.combine(end_date, time.max) ).order_by(TimeEntry.arrival_time).all() - + # Calculate daily and total hours daily_hours = {} total_seconds = 0 - + for entry in entries: if entry.duration: # Only count completed entries entry_date = entry.arrival_time.date() date_str = entry_date.strftime('%Y-%m-%d') - + if date_str not in daily_hours: daily_hours[date_str] = 0 - + daily_hours[date_str] += entry.duration total_seconds += entry.duration - + # Convert seconds to hours for display for date_str in daily_hours: daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours - + total_hours = round(total_seconds / 3600, 2) # Convert to hours - + # Format entries for JSON response formatted_entries = [] for entry in entries: @@ -1479,7 +1479,7 @@ def team_hours_data(): 'duration': entry.duration, 'total_break_duration': entry.total_break_duration }) - + # Add member data to team data team_data.append({ 'user': { @@ -1491,14 +1491,14 @@ def team_hours_data(): 'total_hours': total_hours, 'entries': formatted_entries }) - + # Generate a list of dates in the range for the table header date_range = [] current_date = start_date while current_date <= end_date: date_range.append(current_date.strftime('%Y-%m-%d')) current_date += timedelta(days=1) - + return jsonify({ 'success': True, 'team': { @@ -1519,7 +1519,7 @@ def export(): def get_date_range(period, start_date_str=None, end_date_str=None): """Get start and end date based on period or custom date range.""" today = datetime.now().date() - + if period: if period == 'today': return today, today @@ -1576,7 +1576,7 @@ def export_to_csv(data, filename): writer = csv.DictWriter(output, fieldnames=data[0].keys()) writer.writeheader() writer.writerows(data) - + return Response( output.getvalue(), mimetype='text/csv', @@ -1587,18 +1587,18 @@ def export_to_excel(data, filename): """Export data to Excel format with formatting.""" df = pd.DataFrame(data) output = io.BytesIO() - + with pd.ExcelWriter(output, engine='xlsxwriter') as writer: df.to_excel(writer, sheet_name='TimeTrack Data', index=False) - + # Auto-adjust columns' width worksheet = writer.sheets['TimeTrack Data'] for i, col in enumerate(df.columns): column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2 worksheet.set_column(i, i, column_width) - + output.seek(0) - + return send_file( output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', @@ -1609,11 +1609,11 @@ def export_to_excel(data, filename): def prepare_team_hours_export_data(team, team_data, date_range): """Prepare team hours data for export.""" export_data = [] - + for member_data in team_data: user = member_data['user'] daily_hours = member_data['daily_hours'] - + # Create base row with member info row = { 'Team': team['name'], @@ -1621,26 +1621,26 @@ def prepare_team_hours_export_data(team, team_data, date_range): 'Email': user['email'], 'Total Hours': member_data['total_hours'] } - + # Add daily hours columns for date_str in date_range: formatted_date = datetime.strptime(date_str, '%Y-%m-%d').strftime('%m/%d/%Y') row[formatted_date] = daily_hours.get(date_str, 0.0) - + export_data.append(row) - + return export_data def export_team_hours_to_csv(data, filename): """Export team hours data to CSV format.""" if not data: return None - + output = io.StringIO() writer = csv.DictWriter(output, fieldnames=data[0].keys()) writer.writeheader() writer.writerows(data) - + return Response( output.getvalue(), mimetype='text/csv', @@ -1651,17 +1651,17 @@ def export_team_hours_to_excel(data, filename, team_name): """Export team hours data to Excel format with formatting.""" if not data: return None - + df = pd.DataFrame(data) output = io.BytesIO() - + with pd.ExcelWriter(output, engine='xlsxwriter') as writer: df.to_excel(writer, sheet_name=f'{team_name} Hours', index=False) - + # Get the workbook and worksheet objects workbook = writer.book worksheet = writer.sheets[f'{team_name} Hours'] - + # Create formats header_format = workbook.add_format({ 'bold': True, @@ -1671,17 +1671,17 @@ def export_team_hours_to_excel(data, filename, team_name): 'font_color': 'white', 'border': 1 }) - + # Auto-adjust columns' width and apply formatting for i, col in enumerate(df.columns): column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2 worksheet.set_column(i, i, column_width) - + # Apply header formatting worksheet.write(0, i, col, header_format) - + output.seek(0) - + return send_file( output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', @@ -1694,34 +1694,34 @@ def download_export(): """Handle export download requests.""" export_format = request.args.get('format', 'csv') period = request.args.get('period') - + try: start_date, end_date = get_date_range( - period, - request.args.get('start_date'), + period, + request.args.get('start_date'), request.args.get('end_date') ) except ValueError: flash('Invalid date format. Please use YYYY-MM-DD format.') return redirect(url_for('export')) - + # Query entries within the date range start_datetime = datetime.combine(start_date, time.min) end_datetime = datetime.combine(end_date, time.max) - + entries = TimeEntry.query.filter( TimeEntry.arrival_time >= start_datetime, TimeEntry.arrival_time <= end_datetime ).order_by(TimeEntry.arrival_time).all() - + if not entries: flash('No entries found for the selected date range.') return redirect(url_for('export')) - + # Prepare data and filename data = prepare_export_data(entries) filename = f"timetrack_export_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}" - + # Export based on format if export_format == 'csv': return export_to_csv(data, filename) @@ -1737,69 +1737,69 @@ def download_export(): def download_team_hours_export(): """Handle team hours export download requests.""" export_format = request.args.get('format', 'csv') - + # Get the current user's team team = Team.query.get(g.user.team_id) - + if not team: flash('You are not assigned to any team.') return redirect(url_for('team_hours')) - + # Get date range from query parameters or use current week as default today = datetime.now().date() start_of_week = today - timedelta(days=today.weekday()) end_of_week = start_of_week + timedelta(days=6) - + start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d')) end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d')) include_self = request.args.get('include_self', 'false') == 'true' - + try: start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date() end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() except ValueError: flash('Invalid date format.') return redirect(url_for('team_hours')) - + # Get all team members team_members = User.query.filter_by(team_id=team.id).all() - + # Prepare data structure for team members' hours team_data = [] - + for member in team_members: # Skip if the member is the current user (team leader) and include_self is False if member.id == g.user.id and not include_self: continue - + # Get time entries for this member in the date range entries = TimeEntry.query.filter( TimeEntry.user_id == member.id, TimeEntry.arrival_time >= datetime.combine(start_date, time.min), TimeEntry.arrival_time <= datetime.combine(end_date, time.max) ).order_by(TimeEntry.arrival_time).all() - + # Calculate daily and total hours daily_hours = {} total_seconds = 0 - + for entry in entries: if entry.duration: # Only count completed entries entry_date = entry.arrival_time.date() date_str = entry_date.strftime('%Y-%m-%d') - + if date_str not in daily_hours: daily_hours[date_str] = 0 - + daily_hours[date_str] += entry.duration total_seconds += entry.duration - + # Convert seconds to hours for display for date_str in daily_hours: daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours - + total_hours = round(total_seconds / 3600, 2) # Convert to hours - + # Add member data to team data team_data.append({ 'user': { @@ -1810,30 +1810,30 @@ def download_team_hours_export(): 'daily_hours': daily_hours, 'total_hours': total_hours }) - + if not team_data: flash('No team member data found for the selected date range.') return redirect(url_for('team_hours')) - + # Generate a list of dates in the range date_range = [] current_date = start_date while current_date <= end_date: date_range.append(current_date.strftime('%Y-%m-%d')) current_date += timedelta(days=1) - + # Prepare data for export team_info = { 'id': team.id, 'name': team.name, 'description': team.description } - + export_data = prepare_team_hours_export_data(team_info, team_data, date_range) - + # Generate filename filename = f"{team.name.replace(' ', '_')}_hours_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}" - + # Export based on format if export_format == 'csv': response = export_team_hours_to_csv(export_data, filename) @@ -1852,7 +1852,7 @@ def download_team_hours_export(): else: flash('Invalid export format.') return redirect(url_for('team_hours')) - + if __name__ == '__main__': port = int(os.environ.get('PORT', 5000)) app.run(debug=False, host='0.0.0.0', port=port) \ No newline at end of file diff --git a/fly.toml b/fly.toml index bd2900f..a46b6e7 100644 --- a/fly.toml +++ b/fly.toml @@ -23,6 +23,11 @@ primary_region = 'fra' MAIL_USERNAME = "jens@luedicke.cloud" MAIL_DEFAULT_SENDER = "jens@luedicke.cloud" + +[mounts] + source = "timetrack_data" + destination = "/data" + [[vm]] cpu_kind = 'shared' cpus = 1