""" Raccoon Timekeeper - Self-hosted Time Tracking Application A Flask-based time tracking system with user authentication and weekly timesheets """ from flask import Flask, render_template, request, jsonify, redirect, url_for, flash from flask_sqlalchemy import SQLAlchemy from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user from werkzeug.security import generate_password_hash, check_password_hash from datetime import datetime, timedelta from functools import wraps import os import re import click import secrets app = Flask(__name__) app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', secrets.token_hex(32)) app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///timekeeper.db') app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False db = SQLAlchemy(app) login_manager = LoginManager(app) login_manager.login_view = 'login' login_manager.login_message = 'Please log in to access this page.' login_manager.login_message_category = 'info' # ===================================================== # DATABASE MODELS # ===================================================== class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) email = db.Column(db.String(120), unique=True, nullable=False) password_hash = db.Column(db.String(256), nullable=False) display_name = db.Column(db.String(100), nullable=True) is_admin = db.Column(db.Boolean, default=False) is_active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) last_login = db.Column(db.DateTime, nullable=True) tasks = db.relationship('Task', backref='owner', lazy=True, cascade='all, delete-orphan') entries = db.relationship('TimeEntry', backref='owner', lazy=True, cascade='all, delete-orphan') def set_password(self, password): self.password_hash = generate_password_hash(password) def check_password(self, password): return check_password_hash(self.password_hash, password) def to_dict(self): return { 'id': self.id, 'username': self.username, 'email': self.email, 'display_name': self.display_name or self.username, 'is_admin': self.is_admin, 'is_active': self.is_active, 'created_at': self.created_at.isoformat() if self.created_at else None, 'last_login': self.last_login.isoformat() if self.last_login else None } class Task(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) name = db.Column(db.String(100), nullable=False) active = db.Column(db.Boolean, default=True) created_at = db.Column(db.DateTime, default=datetime.utcnow) entries = db.relationship('TimeEntry', backref='task', lazy=True, cascade='all, delete-orphan') __table_args__ = (db.UniqueConstraint('user_id', 'name', name='unique_task_per_user'),) def to_dict(self): return { 'id': self.id, 'name': self.name, 'active': self.active, 'created_at': self.created_at.isoformat() if self.created_at else None } class TimeEntry(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False) task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False) date = db.Column(db.Date, nullable=False) hours = db.Column(db.Integer, default=0) minutes = db.Column(db.Integer, default=0) total_minutes = db.Column(db.Integer, default=0) notes = db.Column(db.String(500), default='') created_at = db.Column(db.DateTime, default=datetime.utcnow) def to_dict(self): return { 'id': self.id, 'task_id': self.task_id, 'task_name': self.task.name if self.task else 'Unknown', 'date': self.date.isoformat(), 'hours': self.hours, 'minutes': self.minutes, 'total_minutes': self.total_minutes, 'notes': self.notes, 'created_at': self.created_at.isoformat() if self.created_at else None } # ===================================================== # LOGIN MANAGER # ===================================================== @login_manager.user_loader def load_user(user_id): return User.query.get(int(user_id)) # ===================================================== # DECORATORS # ===================================================== def admin_required(f): """Decorator to require admin privileges.""" @wraps(f) def decorated_function(*args, **kwargs): if not current_user.is_authenticated or not current_user.is_admin: flash('You need administrator privileges to access this page.', 'error') return redirect(url_for('index')) return f(*args, **kwargs) return decorated_function # ===================================================== # UTILITY FUNCTIONS # ===================================================== def parse_time_input(time_str): """Parse various time input formats into hours and minutes.""" if not time_str: return None time_str = str(time_str).strip().lower() if ':' in time_str: parts = time_str.split(':') try: hours = int(parts[0]) if parts[0] else 0 minutes = int(parts[1]) if len(parts) > 1 and parts[1] else 0 return {'hours': hours, 'minutes': minutes, 'total_minutes': (hours * 60) + minutes} except ValueError: return None total_minutes = 0 hour_match = re.search(r'(\d+(?:\.\d+)?)\s*h', time_str) minute_match = re.search(r'(\d+)\s*m', time_str) if hour_match: total_minutes += float(hour_match.group(1)) * 60 if minute_match: total_minutes += int(minute_match.group(1)) if total_minutes > 0: return {'hours': int(total_minutes // 60), 'minutes': int(total_minutes % 60), 'total_minutes': int(total_minutes)} try: decimal = float(time_str) if decimal > 0: total_minutes = int(decimal * 60) return {'hours': total_minutes // 60, 'minutes': total_minutes % 60, 'total_minutes': total_minutes} except ValueError: pass return None def get_monday(date): """Get the Monday of the week containing the given date.""" days_since_monday = date.weekday() return date - timedelta(days=days_since_monday) def format_minutes(total_minutes): """Format minutes as H:MM string.""" if not total_minutes: return '0:00' hours = total_minutes // 60 mins = total_minutes % 60 return f'{hours}:{mins:02d}' def is_setup_complete(): """Check if initial setup has been completed.""" return User.query.filter_by(is_admin=True).first() is not None def create_default_tasks(user): """Create default tasks for a new user.""" default_tasks = ['General Work', 'Meetings', 'Admin', 'Training'] for task_name in default_tasks: task = Task(user_id=user.id, name=task_name, active=True) db.session.add(task) # ===================================================== # ROUTES - AUTHENTICATION # ===================================================== @app.route('/setup', methods=['GET', 'POST']) def setup(): """Initial setup page to create admin user.""" if is_setup_complete(): return redirect(url_for('login')) if request.method == 'POST': username = request.form.get('username', '').strip() email = request.form.get('email', '').strip() password = request.form.get('password', '') confirm_password = request.form.get('confirm_password', '') display_name = request.form.get('display_name', '').strip() errors = [] if not username or len(username) < 3: errors.append('Username must be at least 3 characters.') if not email or '@' not in email: errors.append('Please enter a valid email address.') if not password or len(password) < 8: errors.append('Password must be at least 8 characters.') if password != confirm_password: errors.append('Passwords do not match.') if errors: for error in errors: flash(error, 'error') return render_template('setup.html') admin = User( username=username, email=email, display_name=display_name or username, is_admin=True, is_active=True ) admin.set_password(password) db.session.add(admin) db.session.flush() # Get the user ID create_default_tasks(admin) db.session.commit() flash('Admin account created successfully! Please log in.', 'success') return redirect(url_for('login')) return render_template('setup.html') @app.route('/login', methods=['GET', 'POST']) def login(): """User login page.""" if not is_setup_complete(): return redirect(url_for('setup')) if current_user.is_authenticated: return redirect(url_for('index')) if request.method == 'POST': username = request.form.get('username', '').strip() password = request.form.get('password', '') remember = request.form.get('remember', False) user = User.query.filter( (User.username == username) | (User.email == username) ).first() if user and user.check_password(password): if not user.is_active: flash('Your account has been deactivated. Contact an administrator.', 'error') return render_template('login.html') login_user(user, remember=remember) user.last_login = datetime.utcnow() db.session.commit() next_page = request.args.get('next') return redirect(next_page if next_page else url_for('index')) else: flash('Invalid username or password.', 'error') return render_template('login.html') @app.route('/logout') @login_required def logout(): """Log out the current user.""" logout_user() flash('You have been logged out.', 'info') return redirect(url_for('login')) @app.route('/change-password', methods=['GET', 'POST']) @login_required def change_password(): """Change password page.""" if request.method == 'POST': current_password = request.form.get('current_password', '') new_password = request.form.get('new_password', '') confirm_password = request.form.get('confirm_password', '') if not current_user.check_password(current_password): flash('Current password is incorrect.', 'error') elif len(new_password) < 8: flash('New password must be at least 8 characters.', 'error') elif new_password != confirm_password: flash('New passwords do not match.', 'error') else: current_user.set_password(new_password) db.session.commit() flash('Password changed successfully!', 'success') return redirect(url_for('index')) return render_template('change_password.html') # ===================================================== # ROUTES - PAGES # ===================================================== @app.route('/') @login_required def index(): """Main time logging page.""" return render_template('index.html') @app.route('/settings') @login_required def settings(): """Settings and task management page.""" return render_template('settings.html') @app.route('/admin/users') @login_required @admin_required def admin_users(): """Admin user management page.""" return render_template('admin_users.html') # ===================================================== # API ROUTES - TASKS (User-scoped) # ===================================================== @app.route('/api/tasks', methods=['GET']) @login_required def get_tasks(): """Get all tasks for current user.""" include_inactive = request.args.get('include_inactive', 'false').lower() == 'true' query = Task.query.filter_by(user_id=current_user.id) if not include_inactive: query = query.filter_by(active=True) tasks = query.order_by(Task.name).all() return jsonify([t.to_dict() for t in tasks]) @app.route('/api/tasks', methods=['POST']) @login_required def add_task(): """Add a new task for current user.""" data = request.get_json() name = data.get('name', '').strip() if not name: return jsonify({'success': False, 'message': 'Task name cannot be empty'}), 400 existing = Task.query.filter_by(user_id=current_user.id).filter( db.func.lower(Task.name) == name.lower() ).first() if existing: return jsonify({'success': False, 'message': 'Task already exists'}), 400 task = Task(user_id=current_user.id, name=name, active=True) db.session.add(task) db.session.commit() return jsonify({'success': True, 'message': 'Task added successfully', 'task': task.to_dict()}) @app.route('/api/tasks/', methods=['PUT']) @login_required def update_task(task_id): """Update a task (owned by current user).""" task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404() data = request.get_json() if 'name' in data: task.name = data['name'].strip() if 'active' in data: task.active = data['active'] db.session.commit() return jsonify({'success': True, 'message': 'Task updated successfully'}) @app.route('/api/tasks/', methods=['DELETE']) @login_required def delete_task(task_id): """Delete a task (owned by current user).""" task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404() db.session.delete(task) db.session.commit() return jsonify({'success': True, 'message': 'Task deleted successfully'}) # ===================================================== # API ROUTES - TIME ENTRIES (User-scoped) # ===================================================== @app.route('/api/entries', methods=['GET']) @login_required def get_entries(): """Get time entries for current user.""" start_date = request.args.get('start_date') end_date = request.args.get('end_date') query = TimeEntry.query.filter_by(user_id=current_user.id) if start_date: query = query.filter(TimeEntry.date >= datetime.fromisoformat(start_date).date()) if end_date: query = query.filter(TimeEntry.date <= datetime.fromisoformat(end_date).date()) entries = query.order_by(TimeEntry.date.desc(), TimeEntry.created_at.desc()).all() return jsonify([e.to_dict() for e in entries]) @app.route('/api/entries', methods=['POST']) @login_required def add_entry(): """Add a new time entry for current user.""" data = request.get_json() task_id = data.get('task_id') date_str = data.get('date') time_str = data.get('time') notes = data.get('notes', '') if not task_id or not date_str or not time_str: return jsonify({'success': False, 'message': 'Missing required fields'}), 400 parsed_time = parse_time_input(time_str) if not parsed_time: return jsonify({'success': False, 'message': 'Invalid time format'}), 400 task = Task.query.filter_by(id=task_id, user_id=current_user.id).first() if not task: return jsonify({'success': False, 'message': 'Task not found'}), 404 entry = TimeEntry( user_id=current_user.id, task_id=task_id, date=datetime.fromisoformat(date_str).date(), hours=parsed_time['hours'], minutes=parsed_time['minutes'], total_minutes=parsed_time['total_minutes'], notes=notes ) db.session.add(entry) db.session.commit() return jsonify({'success': True, 'message': 'Time entry added successfully', 'entry': entry.to_dict()}) @app.route('/api/entries/', methods=['PUT']) @login_required def update_entry(entry_id): """Update a time entry (owned by current user).""" entry = TimeEntry.query.filter_by(id=entry_id, user_id=current_user.id).first_or_404() data = request.get_json() if 'task_id' in data: task = Task.query.filter_by(id=data['task_id'], user_id=current_user.id).first() if task: entry.task_id = data['task_id'] if 'date' in data: entry.date = datetime.fromisoformat(data['date']).date() if 'time' in data: parsed_time = parse_time_input(data['time']) if parsed_time: entry.hours = parsed_time['hours'] entry.minutes = parsed_time['minutes'] entry.total_minutes = parsed_time['total_minutes'] if 'notes' in data: entry.notes = data['notes'] db.session.commit() return jsonify({'success': True, 'message': 'Entry updated successfully'}) @app.route('/api/entries/', methods=['DELETE']) @login_required def delete_entry(entry_id): """Delete a time entry (owned by current user).""" entry = TimeEntry.query.filter_by(id=entry_id, user_id=current_user.id).first_or_404() db.session.delete(entry) db.session.commit() return jsonify({'success': True, 'message': 'Entry deleted successfully'}) # ===================================================== # API ROUTES - WEEKLY SUMMARY (User-scoped) # ===================================================== @app.route('/api/weekly-summary') @login_required def get_weekly_summary(): """Get weekly summary for current user.""" week_start_str = request.args.get('week_start') if week_start_str: week_start = get_monday(datetime.fromisoformat(week_start_str).date()) else: week_start = get_monday(datetime.now().date()) week_end = week_start + timedelta(days=6) entries = TimeEntry.query.filter( TimeEntry.user_id == current_user.id, TimeEntry.date >= week_start, TimeEntry.date <= week_end ).all() task_summary = {} daily_totals = {i: 0 for i in range(7)} for entry in entries: task_name = entry.task.name if entry.task else 'Unknown' day_index = entry.date.weekday() if task_name not in task_summary: task_summary[task_name] = { 'task_id': entry.task_id, 'task_name': task_name, 'days': {i: 0 for i in range(7)}, 'total_minutes': 0 } task_summary[task_name]['days'][day_index] += entry.total_minutes task_summary[task_name]['total_minutes'] += entry.total_minutes daily_totals[day_index] += entry.total_minutes tasks = sorted(task_summary.values(), key=lambda x: x['task_name']) grand_total = sum(daily_totals.values()) return jsonify({ 'week_start': week_start.isoformat(), 'week_end': week_end.isoformat(), 'tasks': tasks, 'daily_totals': daily_totals, 'grand_total': grand_total, 'entries': [e.to_dict() for e in entries] }) # ===================================================== # API ROUTES - ADMIN USER MANAGEMENT # ===================================================== @app.route('/api/admin/users', methods=['GET']) @login_required @admin_required def get_users(): """Get all users (admin only).""" users = User.query.order_by(User.username).all() return jsonify([u.to_dict() for u in users]) @app.route('/api/admin/users', methods=['POST']) @login_required @admin_required def create_user(): """Create a new user (admin only).""" data = request.get_json() username = data.get('username', '').strip() email = data.get('email', '').strip() password = data.get('password', '') display_name = data.get('display_name', '').strip() is_admin = data.get('is_admin', False) errors = [] if not username or len(username) < 3: errors.append('Username must be at least 3 characters.') if not email or '@' not in email: errors.append('Valid email required.') if not password or len(password) < 8: errors.append('Password must be at least 8 characters.') if User.query.filter_by(username=username).first(): errors.append('Username already exists.') if User.query.filter_by(email=email).first(): errors.append('Email already exists.') if errors: return jsonify({'success': False, 'message': ' '.join(errors)}), 400 user = User( username=username, email=email, display_name=display_name or username, is_admin=is_admin, is_active=True ) user.set_password(password) db.session.add(user) db.session.flush() create_default_tasks(user) db.session.commit() return jsonify({'success': True, 'message': 'User created successfully', 'user': user.to_dict()}) @app.route('/api/admin/users/', methods=['PUT']) @login_required @admin_required def update_user(user_id): """Update a user (admin only).""" user = User.query.get_or_404(user_id) data = request.get_json() if user.id == current_user.id and 'is_active' in data and not data['is_active']: return jsonify({'success': False, 'message': 'Cannot deactivate your own account'}), 400 if user.id == current_user.id and 'is_admin' in data and not data['is_admin']: return jsonify({'success': False, 'message': 'Cannot remove your own admin status'}), 400 if 'display_name' in data: user.display_name = data['display_name'].strip() if 'email' in data: user.email = data['email'].strip() if 'is_admin' in data: user.is_admin = data['is_admin'] if 'is_active' in data: user.is_active = data['is_active'] if 'password' in data and data['password']: if len(data['password']) < 8: return jsonify({'success': False, 'message': 'Password must be at least 8 characters'}), 400 user.set_password(data['password']) db.session.commit() return jsonify({'success': True, 'message': 'User updated successfully'}) @app.route('/api/admin/users/', methods=['DELETE']) @login_required @admin_required def delete_user(user_id): """Delete a user (admin only).""" user = User.query.get_or_404(user_id) if user.id == current_user.id: return jsonify({'success': False, 'message': 'Cannot delete your own account'}), 400 db.session.delete(user) db.session.commit() return jsonify({'success': True, 'message': 'User deleted successfully'}) # ===================================================== # CLI COMMANDS # ===================================================== @app.cli.command('init-db') def init_db_command(): """Initialize the database.""" db.create_all() click.echo('Database initialized.') @app.cli.command('reset-admin') @click.option('--username', prompt='Admin username', help='Username for the admin account') @click.option('--email', prompt='Admin email', help='Email for the admin account') @click.option('--password', prompt='Admin password', hide_input=True, confirmation_prompt=True) def reset_admin_command(username, email, password): """Reset or create the admin user.""" admin = User.query.filter_by(is_admin=True).first() if admin: admin.username = username admin.email = email admin.set_password(password) admin.is_active = True click.echo(f'Admin user "{username}" has been reset.') else: admin = User(username=username, email=email, display_name=username, is_admin=True, is_active=True) admin.set_password(password) db.session.add(admin) db.session.flush() create_default_tasks(admin) click.echo(f'Admin user "{username}" has been created.') db.session.commit() @app.cli.command('create-user') @click.option('--username', prompt='Username') @click.option('--email', prompt='Email') @click.option('--password', prompt='Password', hide_input=True, confirmation_prompt=True) @click.option('--admin', is_flag=True, help='Make this user an admin') def create_user_command(username, email, password, admin): """Create a new user from command line.""" if User.query.filter_by(username=username).first(): click.echo(f'Error: User "{username}" already exists.') return if User.query.filter_by(email=email).first(): click.echo(f'Error: Email "{email}" already exists.') return user = User(username=username, email=email, display_name=username, is_admin=admin, is_active=True) user.set_password(password) db.session.add(user) db.session.flush() create_default_tasks(user) db.session.commit() click.echo(f'User "{username}" created successfully.') @app.cli.command('list-users') def list_users_command(): """List all users.""" users = User.query.all() if not users: click.echo('No users found.') return click.echo('\nUsers:') click.echo('-' * 60) for user in users: status = '✓' if user.is_active else '✗' admin = ' [ADMIN]' if user.is_admin else '' click.echo(f'{status} {user.username} ({user.email}){admin}') click.echo('-' * 60) click.echo(f'Total: {len(users)} users') # ===================================================== # CONTEXT PROCESSOR # ===================================================== @app.context_processor def utility_processor(): return {'format_minutes': format_minutes} # ===================================================== # INITIALIZATION # ===================================================== def init_db(): with app.app_context(): db.create_all() if __name__ == '__main__': init_db() app.run(host='0.0.0.0', port=5000, debug=os.environ.get('DEBUG', 'false').lower() == 'true')