diff --git a/app.py b/app.py index 007fa38..28f0dbb 100644 --- a/app.py +++ b/app.py @@ -1,39 +1,78 @@ """ Raccoon Timekeeper - Self-hosted Time Tracking Application +A Flask-based time tracking system with user authentication and weekly timesheets """ -import os -import re -import uuid +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 - -from flask import Flask, render_template, request, jsonify, redirect, url_for -from flask_sqlalchemy import SQLAlchemy -from dotenv import load_dotenv - -load_dotenv() +import os +import re +import click +import secrets app = Flask(__name__) -app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'raccoon-secret-key-change-me') +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): - __tablename__ = 'tasks' - - id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())[:8]) - name = db.Column(db.String(255), nullable=False) + 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) + 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 { @@ -45,24 +84,22 @@ class Task(db.Model): class TimeEntry(db.Model): - __tablename__ = 'time_entries' - - id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())[:8]) - task_id = db.Column(db.String(36), db.ForeignKey('tasks.id'), nullable=False) - task_name = db.Column(db.String(255), nullable=False) + 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.Text, default='') + 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, - 'date': self.date.isoformat() if self.date else None, + '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, @@ -71,158 +108,293 @@ class TimeEntry(db.Model): } -# ============================================================================= +# ===================================================== +# 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 flexible time input formats: - - "1:30" or "1:45" (hours:minutes) - - "1.5" or "1.75" (decimal hours) - - "90m" or "90min" (minutes) - - "1h" or "1hr" (hours) - - "1h 30m" or "1h30m" (combined) - """ + """Parse various time input formats into hours and minutes.""" if not time_str: return None time_str = str(time_str).strip().lower() - # Format: "1:30" (hours:minutes) - match = re.match(r'^(\d+):(\d+)$', time_str) - if match: - hours = int(match.group(1)) - minutes = int(match.group(2)) - return { - 'hours': hours, - 'minutes': minutes, - 'total_minutes': hours * 60 + minutes - } + 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 - # Format: "1h 30m" or "1h30m" or "1hr 30min" - match = re.match(r'^(\d+)\s*h(?:r|our)?s?\s*(\d+)\s*m(?:in)?(?:ute)?s?$', time_str) - if match: - hours = int(match.group(1)) - minutes = int(match.group(2)) - return { - 'hours': hours, - 'minutes': minutes, - 'total_minutes': hours * 60 + minutes - } + total_minutes = 0 + hour_match = re.search(r'(\d+(?:\.\d+)?)\s*h', time_str) + minute_match = re.search(r'(\d+)\s*m', time_str) - # Format: "90m" or "90min" or "90 minutes" - match = re.match(r'^(\d+)\s*m(?:in)?(?:ute)?s?$', time_str) - if match: - total_minutes = int(match.group(1)) - return { - 'hours': total_minutes // 60, - 'minutes': total_minutes % 60, - 'total_minutes': total_minutes - } + if hour_match: + total_minutes += float(hour_match.group(1)) * 60 + if minute_match: + total_minutes += int(minute_match.group(1)) - # Format: "1h" or "1hr" or "1 hour" - match = re.match(r'^(\d+)\s*h(?:r|our)?s?$', time_str) - if match: - hours = int(match.group(1)) - return { - 'hours': hours, - 'minutes': 0, - 'total_minutes': hours * 60 - } + if total_minutes > 0: + return {'hours': int(total_minutes // 60), 'minutes': int(total_minutes % 60), 'total_minutes': int(total_minutes)} - # Format: "1.5" or "1.75" (decimal hours) - match = re.match(r'^(\d+(?:\.\d+)?)$', time_str) - if match: - decimal_hours = float(match.group(1)) - total_minutes = int(round(decimal_hours * 60)) - return { - 'hours': total_minutes // 60, - 'minutes': total_minutes % 60, - 'total_minutes': 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 for a given 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 - minutes = total_minutes % 60 - return f"{hours}:{minutes:02d}" + 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') -# ============================================================================= -# API ROUTES - TASKS -# ============================================================================= +@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.""" + """Get all tasks for current user.""" include_inactive = request.args.get('include_inactive', 'false').lower() == 'true' - query = Task.query + 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([task.to_dict() for task in tasks]) + return jsonify([t.to_dict() for t in tasks]) @app.route('/api/tasks', methods=['POST']) +@login_required def add_task(): - """Add a new 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 - # Check for duplicate - existing = Task.query.filter(db.func.lower(Task.name) == name.lower()).first() + 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(name=name) + 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() - }) + return jsonify({'success': True, 'message': 'Task added successfully', 'task': task.to_dict()}) -@app.route('/api/tasks/', methods=['PUT']) +@app.route('/api/tasks/', methods=['PUT']) +@login_required def update_task(task_id): - """Update a task.""" - task = Task.query.get(task_id) - if not task: - return jsonify({'success': False, 'message': 'Task not found'}), 404 - + """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: @@ -231,45 +403,48 @@ def update_task(task_id): task.active = data['active'] db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Task updated successfully', - 'task': task.to_dict() - }) + return jsonify({'success': True, 'message': 'Task updated successfully'}) -@app.route('/api/tasks/', methods=['DELETE']) +@app.route('/api/tasks/', methods=['DELETE']) +@login_required def delete_task(task_id): - """Delete a task.""" - task = Task.query.get(task_id) - if not task: - return jsonify({'success': False, 'message': 'Task not found'}), 404 - + """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 -# ============================================================================= +# ===================================================== +# API ROUTES - TIME ENTRIES (User-scoped) +# ===================================================== @app.route('/api/entries', methods=['GET']) +@login_required def get_entries(): - """Get time entries with optional date filtering.""" - entries = TimeEntry.query.order_by(TimeEntry.date.desc(), TimeEntry.created_at.desc()).all() - return jsonify([entry.to_dict() for entry in 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.""" + """Add a new time entry for current user.""" data = request.get_json() task_id = data.get('task_id') - task_name = data.get('task_name') date_str = data.get('date') time_str = data.get('time') notes = data.get('notes', '') @@ -277,55 +452,42 @@ def add_entry(): if not task_id or not date_str or not time_str: return jsonify({'success': False, 'message': 'Missing required fields'}), 400 - # Parse time parsed_time = parse_time_input(time_str) if not parsed_time: - return jsonify({ - 'success': False, - 'message': 'Invalid time format. Use formats like 1:30, 1.5, 90m, or 1h 30m' - }), 400 + return jsonify({'success': False, 'message': 'Invalid time format'}), 400 - # Parse date - try: - entry_date = datetime.strptime(date_str, '%Y-%m-%d').date() - except ValueError: - return jsonify({'success': False, 'message': 'Invalid date 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, - task_name=task_name, - date=entry_date, + 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() - }) + return jsonify({'success': True, 'message': 'Time entry added successfully', 'entry': entry.to_dict()}) -@app.route('/api/entries/', methods=['PUT']) +@app.route('/api/entries/', methods=['PUT']) +@login_required def update_entry(entry_id): - """Update a time entry.""" - entry = TimeEntry.query.get(entry_id) - if not entry: - return jsonify({'success': False, 'message': 'Entry not found'}), 404 - + """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: - entry.task_id = data['task_id'] - if 'task_name' in data: - entry.task_name = data['task_name'] + 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.strptime(data['date'], '%Y-%m-%d').date() + entry.date = datetime.fromisoformat(data['date']).date() if 'time' in data: parsed_time = parse_time_input(data['time']) if parsed_time: @@ -336,61 +498,48 @@ def update_entry(entry_id): entry.notes = data['notes'] db.session.commit() - - return jsonify({ - 'success': True, - 'message': 'Entry updated successfully', - 'entry': entry.to_dict() - }) + return jsonify({'success': True, 'message': 'Entry updated successfully'}) -@app.route('/api/entries/', methods=['DELETE']) +@app.route('/api/entries/', methods=['DELETE']) +@login_required def delete_entry(entry_id): - """Delete a time entry.""" - entry = TimeEntry.query.get(entry_id) - if not entry: - return jsonify({'success': False, 'message': 'Entry not found'}), 404 - + """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 -# ============================================================================= +# ===================================================== +# API ROUTES - WEEKLY SUMMARY (User-scoped) +# ===================================================== @app.route('/api/weekly-summary') +@login_required def get_weekly_summary(): - """Get weekly summary for a given week.""" + """Get weekly summary for current user.""" week_start_str = request.args.get('week_start') if week_start_str: - try: - week_start = datetime.strptime(week_start_str, '%Y-%m-%d').date() - except ValueError: - week_start = get_monday(datetime.now().date()) + week_start = get_monday(datetime.fromisoformat(week_start_str).date()) else: week_start = get_monday(datetime.now().date()) - # Ensure we start on Monday - week_start = get_monday(week_start) week_end = week_start + timedelta(days=6) - # Get entries for this week entries = TimeEntry.query.filter( + TimeEntry.user_id == current_user.id, TimeEntry.date >= week_start, TimeEntry.date <= week_end ).all() - # Group by task task_summary = {} - daily_totals = {i: 0 for i in range(7)} # Mon=0, Sun=6 + daily_totals = {i: 0 for i in range(7)} for entry in entries: - task_name = entry.task_name - day_index = entry.date.weekday() # Mon=0, Sun=6 + 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] = { @@ -404,7 +553,7 @@ def get_weekly_summary(): task_summary[task_name]['total_minutes'] += entry.total_minutes daily_totals[day_index] += entry.total_minutes - tasks = list(task_summary.values()) + tasks = sorted(task_summary.values(), key=lambda x: x['task_name']) grand_total = sum(daily_totals.values()) return jsonify({ @@ -417,29 +566,205 @@ def get_weekly_summary(): }) -# ============================================================================= -# DATABASE INITIALIZATION -# ============================================================================= +# ===================================================== +# 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(): - """Initialize the database.""" with app.app_context(): db.create_all() - - # Add some default tasks if none exist - if Task.query.count() == 0: - default_tasks = ['General Work', 'Meetings', 'Admin', 'Training'] - for task_name in default_tasks: - task = Task(name=task_name) - db.session.add(task) - db.session.commit() - print("Default tasks created.") -# ============================================================================= -# MAIN -# ============================================================================= - if __name__ == '__main__': init_db() - app.run(debug=os.environ.get('DEBUG', 'false').lower() == 'true', host='0.0.0.0', port=5000) + app.run(host='0.0.0.0', port=5000, debug=os.environ.get('DEBUG', 'false').lower() == 'true')