Add main Flask application
This commit is contained in:
445
app.py
Normal file
445
app.py
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
"""
|
||||||
|
Raccoon Timekeeper - Self-hosted Time Tracking Application
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import uuid
|
||||||
|
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()
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'raccoon-secret-key-change-me')
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', 'sqlite:///timekeeper.db')
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
db = SQLAlchemy(app)
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE MODELS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
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)
|
||||||
|
active = db.Column(db.Boolean, default=True)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
entries = db.relationship('TimeEntry', backref='task', lazy=True)
|
||||||
|
|
||||||
|
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):
|
||||||
|
__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)
|
||||||
|
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='')
|
||||||
|
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,
|
||||||
|
'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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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)
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
|
}
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_monday(date):
|
||||||
|
"""Get the Monday of the week for a 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."""
|
||||||
|
hours = total_minutes // 60
|
||||||
|
minutes = total_minutes % 60
|
||||||
|
return f"{hours}:{minutes:02d}"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# ROUTES - PAGES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
"""Main time logging page."""
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/settings')
|
||||||
|
def settings():
|
||||||
|
"""Settings and task management page."""
|
||||||
|
return render_template('settings.html')
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API ROUTES - TASKS
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.route('/api/tasks', methods=['GET'])
|
||||||
|
def get_tasks():
|
||||||
|
"""Get all tasks."""
|
||||||
|
include_inactive = request.args.get('include_inactive', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
query = Task.query
|
||||||
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tasks', methods=['POST'])
|
||||||
|
def add_task():
|
||||||
|
"""Add a new task."""
|
||||||
|
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()
|
||||||
|
if existing:
|
||||||
|
return jsonify({'success': False, 'message': 'Task already exists'}), 400
|
||||||
|
|
||||||
|
task = Task(name=name)
|
||||||
|
db.session.add(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'message': 'Task added successfully',
|
||||||
|
'task': task.to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tasks/<task_id>', methods=['PUT'])
|
||||||
|
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
|
||||||
|
|
||||||
|
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',
|
||||||
|
'task': task.to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/tasks/<task_id>', methods=['DELETE'])
|
||||||
|
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
|
||||||
|
|
||||||
|
db.session.delete(task)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Task deleted successfully'})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API ROUTES - TIME ENTRIES
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.route('/api/entries', methods=['GET'])
|
||||||
|
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])
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/entries', methods=['POST'])
|
||||||
|
def add_entry():
|
||||||
|
"""Add a new time entry."""
|
||||||
|
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', '')
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# Parse date
|
||||||
|
try:
|
||||||
|
entry_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({'success': False, 'message': 'Invalid date format'}), 400
|
||||||
|
|
||||||
|
entry = TimeEntry(
|
||||||
|
task_id=task_id,
|
||||||
|
task_name=task_name,
|
||||||
|
date=entry_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/<entry_id>', methods=['PUT'])
|
||||||
|
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
|
||||||
|
|
||||||
|
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']
|
||||||
|
if 'date' in data:
|
||||||
|
entry.date = datetime.strptime(data['date'], '%Y-%m-%d').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',
|
||||||
|
'entry': entry.to_dict()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/entries/<entry_id>', methods=['DELETE'])
|
||||||
|
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
|
||||||
|
|
||||||
|
db.session.delete(entry)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'message': 'Entry deleted successfully'})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# API ROUTES - WEEKLY SUMMARY
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@app.route('/api/weekly-summary')
|
||||||
|
def get_weekly_summary():
|
||||||
|
"""Get weekly summary for a given week."""
|
||||||
|
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())
|
||||||
|
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.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
|
||||||
|
|
||||||
|
for entry in entries:
|
||||||
|
task_name = entry.task_name
|
||||||
|
day_index = entry.date.weekday() # Mon=0, Sun=6
|
||||||
|
|
||||||
|
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 = list(task_summary.values())
|
||||||
|
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]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DATABASE 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)
|
||||||
Reference in New Issue
Block a user