Add user authentication and multi-user support
- Add Flask-Login for session management - First-run setup page creates admin account - Admin can manage users (create, edit, delete, deactivate) - User isolation: each user sees only their own data - CLI commands: reset-admin, create-user, list-users - Password change functionality - Role-based access control (admin vs regular user)
This commit is contained in:
735
app.py
735
app.py
@@ -1,39 +1,78 @@
|
|||||||
"""
|
"""
|
||||||
Raccoon Timekeeper - Self-hosted Time Tracking Application
|
Raccoon Timekeeper - Self-hosted Time Tracking Application
|
||||||
|
A Flask-based time tracking system with user authentication and weekly timesheets
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash
|
||||||
import re
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
import uuid
|
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 datetime import datetime, timedelta
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
import os
|
||||||
from flask import Flask, render_template, request, jsonify, redirect, url_for
|
import re
|
||||||
from flask_sqlalchemy import SQLAlchemy
|
import click
|
||||||
from dotenv import load_dotenv
|
import secrets
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
app = Flask(__name__)
|
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_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///timekeeper.db')
|
||||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
db = SQLAlchemy(app)
|
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
|
# 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):
|
class Task(db.Model):
|
||||||
__tablename__ = 'tasks'
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())[:8])
|
name = db.Column(db.String(100), nullable=False)
|
||||||
name = db.Column(db.String(255), nullable=False)
|
|
||||||
active = db.Column(db.Boolean, default=True)
|
active = db.Column(db.Boolean, default=True)
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
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):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@@ -45,24 +84,22 @@ class Task(db.Model):
|
|||||||
|
|
||||||
|
|
||||||
class TimeEntry(db.Model):
|
class TimeEntry(db.Model):
|
||||||
__tablename__ = 'time_entries'
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())[:8])
|
task_id = db.Column(db.Integer, db.ForeignKey('task.id'), nullable=False)
|
||||||
task_id = db.Column(db.String(36), db.ForeignKey('tasks.id'), nullable=False)
|
|
||||||
task_name = db.Column(db.String(255), nullable=False)
|
|
||||||
date = db.Column(db.Date, nullable=False)
|
date = db.Column(db.Date, nullable=False)
|
||||||
hours = db.Column(db.Integer, default=0)
|
hours = db.Column(db.Integer, default=0)
|
||||||
minutes = db.Column(db.Integer, default=0)
|
minutes = db.Column(db.Integer, default=0)
|
||||||
total_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)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
'id': self.id,
|
'id': self.id,
|
||||||
'task_id': self.task_id,
|
'task_id': self.task_id,
|
||||||
'task_name': self.task_name,
|
'task_name': self.task.name if self.task else 'Unknown',
|
||||||
'date': self.date.isoformat() if self.date else None,
|
'date': self.date.isoformat(),
|
||||||
'hours': self.hours,
|
'hours': self.hours,
|
||||||
'minutes': self.minutes,
|
'minutes': self.minutes,
|
||||||
'total_minutes': self.total_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
|
# UTILITY FUNCTIONS
|
||||||
# =============================================================================
|
# =====================================================
|
||||||
|
|
||||||
def parse_time_input(time_str):
|
def parse_time_input(time_str):
|
||||||
"""
|
"""Parse various time input formats into hours and minutes."""
|
||||||
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)
|
|
||||||
"""
|
|
||||||
if not time_str:
|
if not time_str:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
time_str = str(time_str).strip().lower()
|
time_str = str(time_str).strip().lower()
|
||||||
|
|
||||||
# Format: "1:30" (hours:minutes)
|
if ':' in time_str:
|
||||||
match = re.match(r'^(\d+):(\d+)$', time_str)
|
parts = time_str.split(':')
|
||||||
if match:
|
try:
|
||||||
hours = int(match.group(1))
|
hours = int(parts[0]) if parts[0] else 0
|
||||||
minutes = int(match.group(2))
|
minutes = int(parts[1]) if len(parts) > 1 and parts[1] else 0
|
||||||
return {
|
return {'hours': hours, 'minutes': minutes, 'total_minutes': (hours * 60) + minutes}
|
||||||
'hours': hours,
|
except ValueError:
|
||||||
'minutes': minutes,
|
return None
|
||||||
'total_minutes': hours * 60 + minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
# Format: "1h 30m" or "1h30m" or "1hr 30min"
|
total_minutes = 0
|
||||||
match = re.match(r'^(\d+)\s*h(?:r|our)?s?\s*(\d+)\s*m(?:in)?(?:ute)?s?$', time_str)
|
hour_match = re.search(r'(\d+(?:\.\d+)?)\s*h', time_str)
|
||||||
if match:
|
minute_match = re.search(r'(\d+)\s*m', time_str)
|
||||||
hours = int(match.group(1))
|
|
||||||
minutes = int(match.group(2))
|
|
||||||
return {
|
|
||||||
'hours': hours,
|
|
||||||
'minutes': minutes,
|
|
||||||
'total_minutes': hours * 60 + minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
# Format: "90m" or "90min" or "90 minutes"
|
if hour_match:
|
||||||
match = re.match(r'^(\d+)\s*m(?:in)?(?:ute)?s?$', time_str)
|
total_minutes += float(hour_match.group(1)) * 60
|
||||||
if match:
|
if minute_match:
|
||||||
total_minutes = int(match.group(1))
|
total_minutes += int(minute_match.group(1))
|
||||||
return {
|
|
||||||
'hours': total_minutes // 60,
|
|
||||||
'minutes': total_minutes % 60,
|
|
||||||
'total_minutes': total_minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
# Format: "1h" or "1hr" or "1 hour"
|
if total_minutes > 0:
|
||||||
match = re.match(r'^(\d+)\s*h(?:r|our)?s?$', time_str)
|
return {'hours': int(total_minutes // 60), 'minutes': int(total_minutes % 60), 'total_minutes': int(total_minutes)}
|
||||||
if match:
|
|
||||||
hours = int(match.group(1))
|
|
||||||
return {
|
|
||||||
'hours': hours,
|
|
||||||
'minutes': 0,
|
|
||||||
'total_minutes': hours * 60
|
|
||||||
}
|
|
||||||
|
|
||||||
# Format: "1.5" or "1.75" (decimal hours)
|
try:
|
||||||
match = re.match(r'^(\d+(?:\.\d+)?)$', time_str)
|
decimal = float(time_str)
|
||||||
if match:
|
if decimal > 0:
|
||||||
decimal_hours = float(match.group(1))
|
total_minutes = int(decimal * 60)
|
||||||
total_minutes = int(round(decimal_hours * 60))
|
return {'hours': total_minutes // 60, 'minutes': total_minutes % 60, 'total_minutes': total_minutes}
|
||||||
return {
|
except ValueError:
|
||||||
'hours': total_minutes // 60,
|
pass
|
||||||
'minutes': total_minutes % 60,
|
|
||||||
'total_minutes': total_minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def get_monday(date):
|
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()
|
days_since_monday = date.weekday()
|
||||||
return date - timedelta(days=days_since_monday)
|
return date - timedelta(days=days_since_monday)
|
||||||
|
|
||||||
|
|
||||||
def format_minutes(total_minutes):
|
def format_minutes(total_minutes):
|
||||||
"""Format minutes as H:MM string."""
|
"""Format minutes as H:MM string."""
|
||||||
|
if not total_minutes:
|
||||||
|
return '0:00'
|
||||||
hours = total_minutes // 60
|
hours = total_minutes // 60
|
||||||
minutes = total_minutes % 60
|
mins = total_minutes % 60
|
||||||
return f"{hours}:{minutes:02d}"
|
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
|
# ROUTES - PAGES
|
||||||
# =============================================================================
|
# =====================================================
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
"""Main time logging page."""
|
"""Main time logging page."""
|
||||||
return render_template('index.html')
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
@app.route('/settings')
|
@app.route('/settings')
|
||||||
|
@login_required
|
||||||
def settings():
|
def settings():
|
||||||
"""Settings and task management page."""
|
"""Settings and task management page."""
|
||||||
return render_template('settings.html')
|
return render_template('settings.html')
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
@app.route('/admin/users')
|
||||||
# API ROUTES - TASKS
|
@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'])
|
@app.route('/api/tasks', methods=['GET'])
|
||||||
|
@login_required
|
||||||
def get_tasks():
|
def get_tasks():
|
||||||
"""Get all tasks."""
|
"""Get all tasks for current user."""
|
||||||
include_inactive = request.args.get('include_inactive', 'false').lower() == 'true'
|
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:
|
if not include_inactive:
|
||||||
query = query.filter_by(active=True)
|
query = query.filter_by(active=True)
|
||||||
|
|
||||||
tasks = query.order_by(Task.name).all()
|
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'])
|
@app.route('/api/tasks', methods=['POST'])
|
||||||
|
@login_required
|
||||||
def add_task():
|
def add_task():
|
||||||
"""Add a new task."""
|
"""Add a new task for current user."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
name = data.get('name', '').strip()
|
name = data.get('name', '').strip()
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
return jsonify({'success': False, 'message': 'Task name cannot be empty'}), 400
|
return jsonify({'success': False, 'message': 'Task name cannot be empty'}), 400
|
||||||
|
|
||||||
# Check for duplicate
|
existing = Task.query.filter_by(user_id=current_user.id).filter(
|
||||||
existing = Task.query.filter(db.func.lower(Task.name) == name.lower()).first()
|
db.func.lower(Task.name) == name.lower()
|
||||||
|
).first()
|
||||||
if existing:
|
if existing:
|
||||||
return jsonify({'success': False, 'message': 'Task already exists'}), 400
|
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.add(task)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({'success': True, 'message': 'Task added successfully', 'task': task.to_dict()})
|
||||||
'success': True,
|
|
||||||
'message': 'Task added successfully',
|
|
||||||
'task': task.to_dict()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/tasks/<task_id>', methods=['PUT'])
|
@app.route('/api/tasks/<int:task_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
def update_task(task_id):
|
def update_task(task_id):
|
||||||
"""Update a task."""
|
"""Update a task (owned by current user)."""
|
||||||
task = Task.query.get(task_id)
|
task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404()
|
||||||
if not task:
|
|
||||||
return jsonify({'success': False, 'message': 'Task not found'}), 404
|
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
if 'name' in data:
|
if 'name' in data:
|
||||||
@@ -231,45 +403,48 @@ def update_task(task_id):
|
|||||||
task.active = data['active']
|
task.active = data['active']
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
return jsonify({'success': True, 'message': 'Task updated successfully'})
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'message': 'Task updated successfully',
|
|
||||||
'task': task.to_dict()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/tasks/<task_id>', methods=['DELETE'])
|
@app.route('/api/tasks/<int:task_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
def delete_task(task_id):
|
def delete_task(task_id):
|
||||||
"""Delete a task."""
|
"""Delete a task (owned by current user)."""
|
||||||
task = Task.query.get(task_id)
|
task = Task.query.filter_by(id=task_id, user_id=current_user.id).first_or_404()
|
||||||
if not task:
|
|
||||||
return jsonify({'success': False, 'message': 'Task not found'}), 404
|
|
||||||
|
|
||||||
db.session.delete(task)
|
db.session.delete(task)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({'success': True, 'message': 'Task deleted successfully'})
|
return jsonify({'success': True, 'message': 'Task deleted successfully'})
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =====================================================
|
||||||
# API ROUTES - TIME ENTRIES
|
# API ROUTES - TIME ENTRIES (User-scoped)
|
||||||
# =============================================================================
|
# =====================================================
|
||||||
|
|
||||||
@app.route('/api/entries', methods=['GET'])
|
@app.route('/api/entries', methods=['GET'])
|
||||||
|
@login_required
|
||||||
def get_entries():
|
def get_entries():
|
||||||
"""Get time entries with optional date filtering."""
|
"""Get time entries for current user."""
|
||||||
entries = TimeEntry.query.order_by(TimeEntry.date.desc(), TimeEntry.created_at.desc()).all()
|
start_date = request.args.get('start_date')
|
||||||
return jsonify([entry.to_dict() for entry in entries])
|
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'])
|
@app.route('/api/entries', methods=['POST'])
|
||||||
|
@login_required
|
||||||
def add_entry():
|
def add_entry():
|
||||||
"""Add a new time entry."""
|
"""Add a new time entry for current user."""
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
task_id = data.get('task_id')
|
task_id = data.get('task_id')
|
||||||
task_name = data.get('task_name')
|
|
||||||
date_str = data.get('date')
|
date_str = data.get('date')
|
||||||
time_str = data.get('time')
|
time_str = data.get('time')
|
||||||
notes = data.get('notes', '')
|
notes = data.get('notes', '')
|
||||||
@@ -277,55 +452,42 @@ def add_entry():
|
|||||||
if not task_id or not date_str or not time_str:
|
if not task_id or not date_str or not time_str:
|
||||||
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
|
return jsonify({'success': False, 'message': 'Missing required fields'}), 400
|
||||||
|
|
||||||
# Parse time
|
|
||||||
parsed_time = parse_time_input(time_str)
|
parsed_time = parse_time_input(time_str)
|
||||||
if not parsed_time:
|
if not parsed_time:
|
||||||
return jsonify({
|
return jsonify({'success': False, 'message': 'Invalid time format'}), 400
|
||||||
'success': False,
|
|
||||||
'message': 'Invalid time format. Use formats like 1:30, 1.5, 90m, or 1h 30m'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Parse date
|
task = Task.query.filter_by(id=task_id, user_id=current_user.id).first()
|
||||||
try:
|
if not task:
|
||||||
entry_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
return jsonify({'success': False, 'message': 'Task not found'}), 404
|
||||||
except ValueError:
|
|
||||||
return jsonify({'success': False, 'message': 'Invalid date format'}), 400
|
|
||||||
|
|
||||||
entry = TimeEntry(
|
entry = TimeEntry(
|
||||||
|
user_id=current_user.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
task_name=task_name,
|
date=datetime.fromisoformat(date_str).date(),
|
||||||
date=entry_date,
|
|
||||||
hours=parsed_time['hours'],
|
hours=parsed_time['hours'],
|
||||||
minutes=parsed_time['minutes'],
|
minutes=parsed_time['minutes'],
|
||||||
total_minutes=parsed_time['total_minutes'],
|
total_minutes=parsed_time['total_minutes'],
|
||||||
notes=notes
|
notes=notes
|
||||||
)
|
)
|
||||||
|
|
||||||
db.session.add(entry)
|
db.session.add(entry)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({'success': True, 'message': 'Time entry added successfully', 'entry': entry.to_dict()})
|
||||||
'success': True,
|
|
||||||
'message': 'Time entry added successfully',
|
|
||||||
'entry': entry.to_dict()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/entries/<entry_id>', methods=['PUT'])
|
@app.route('/api/entries/<int:entry_id>', methods=['PUT'])
|
||||||
|
@login_required
|
||||||
def update_entry(entry_id):
|
def update_entry(entry_id):
|
||||||
"""Update a time entry."""
|
"""Update a time entry (owned by current user)."""
|
||||||
entry = TimeEntry.query.get(entry_id)
|
entry = TimeEntry.query.filter_by(id=entry_id, user_id=current_user.id).first_or_404()
|
||||||
if not entry:
|
|
||||||
return jsonify({'success': False, 'message': 'Entry not found'}), 404
|
|
||||||
|
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
|
|
||||||
if 'task_id' in data:
|
if 'task_id' in data:
|
||||||
entry.task_id = data['task_id']
|
task = Task.query.filter_by(id=data['task_id'], user_id=current_user.id).first()
|
||||||
if 'task_name' in data:
|
if task:
|
||||||
entry.task_name = data['task_name']
|
entry.task_id = data['task_id']
|
||||||
if 'date' in data:
|
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:
|
if 'time' in data:
|
||||||
parsed_time = parse_time_input(data['time'])
|
parsed_time = parse_time_input(data['time'])
|
||||||
if parsed_time:
|
if parsed_time:
|
||||||
@@ -336,61 +498,48 @@ def update_entry(entry_id):
|
|||||||
entry.notes = data['notes']
|
entry.notes = data['notes']
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
return jsonify({'success': True, 'message': 'Entry updated successfully'})
|
||||||
return jsonify({
|
|
||||||
'success': True,
|
|
||||||
'message': 'Entry updated successfully',
|
|
||||||
'entry': entry.to_dict()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/entries/<entry_id>', methods=['DELETE'])
|
@app.route('/api/entries/<int:entry_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
def delete_entry(entry_id):
|
def delete_entry(entry_id):
|
||||||
"""Delete a time entry."""
|
"""Delete a time entry (owned by current user)."""
|
||||||
entry = TimeEntry.query.get(entry_id)
|
entry = TimeEntry.query.filter_by(id=entry_id, user_id=current_user.id).first_or_404()
|
||||||
if not entry:
|
|
||||||
return jsonify({'success': False, 'message': 'Entry not found'}), 404
|
|
||||||
|
|
||||||
db.session.delete(entry)
|
db.session.delete(entry)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({'success': True, 'message': 'Entry deleted successfully'})
|
return jsonify({'success': True, 'message': 'Entry deleted successfully'})
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =====================================================
|
||||||
# API ROUTES - WEEKLY SUMMARY
|
# API ROUTES - WEEKLY SUMMARY (User-scoped)
|
||||||
# =============================================================================
|
# =====================================================
|
||||||
|
|
||||||
@app.route('/api/weekly-summary')
|
@app.route('/api/weekly-summary')
|
||||||
|
@login_required
|
||||||
def get_weekly_summary():
|
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')
|
week_start_str = request.args.get('week_start')
|
||||||
|
|
||||||
if week_start_str:
|
if week_start_str:
|
||||||
try:
|
week_start = get_monday(datetime.fromisoformat(week_start_str).date())
|
||||||
week_start = datetime.strptime(week_start_str, '%Y-%m-%d').date()
|
|
||||||
except ValueError:
|
|
||||||
week_start = get_monday(datetime.now().date())
|
|
||||||
else:
|
else:
|
||||||
week_start = get_monday(datetime.now().date())
|
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)
|
week_end = week_start + timedelta(days=6)
|
||||||
|
|
||||||
# Get entries for this week
|
|
||||||
entries = TimeEntry.query.filter(
|
entries = TimeEntry.query.filter(
|
||||||
|
TimeEntry.user_id == current_user.id,
|
||||||
TimeEntry.date >= week_start,
|
TimeEntry.date >= week_start,
|
||||||
TimeEntry.date <= week_end
|
TimeEntry.date <= week_end
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
# Group by task
|
|
||||||
task_summary = {}
|
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:
|
for entry in entries:
|
||||||
task_name = entry.task_name
|
task_name = entry.task.name if entry.task else 'Unknown'
|
||||||
day_index = entry.date.weekday() # Mon=0, Sun=6
|
day_index = entry.date.weekday()
|
||||||
|
|
||||||
if task_name not in task_summary:
|
if task_name not in task_summary:
|
||||||
task_summary[task_name] = {
|
task_summary[task_name] = {
|
||||||
@@ -404,7 +553,7 @@ def get_weekly_summary():
|
|||||||
task_summary[task_name]['total_minutes'] += entry.total_minutes
|
task_summary[task_name]['total_minutes'] += entry.total_minutes
|
||||||
daily_totals[day_index] += 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())
|
grand_total = sum(daily_totals.values())
|
||||||
|
|
||||||
return jsonify({
|
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/<int:user_id>', 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/<int:user_id>', 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():
|
def init_db():
|
||||||
"""Initialize the database."""
|
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
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__':
|
if __name__ == '__main__':
|
||||||
init_db()
|
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')
|
||||||
|
|||||||
Reference in New Issue
Block a user