mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-05 03:01:13 -06:00
Merge pull request #165 from DRYTRIX/Feat-CalendarSupport
Feat calendar support
This commit is contained in:
@@ -768,6 +768,7 @@ def create_app(config=None):
|
||||
from app.routes.weekly_goals import weekly_goals_bp
|
||||
from app.routes.expenses import expenses_bp
|
||||
from app.routes.permissions import permissions_bp
|
||||
from app.routes.calendar import calendar_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
@@ -794,6 +795,7 @@ def create_app(config=None):
|
||||
app.register_blueprint(weekly_goals_bp)
|
||||
app.register_blueprint(expenses_bp)
|
||||
app.register_blueprint(permissions_bp)
|
||||
app.register_blueprint(calendar_bp)
|
||||
|
||||
# Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens)
|
||||
# Only if CSRF is enabled
|
||||
|
||||
@@ -27,6 +27,7 @@ from .weekly_time_goal import WeeklyTimeGoal
|
||||
from .expense import Expense
|
||||
from .permission import Permission, Role
|
||||
from .api_token import ApiToken
|
||||
from .calendar_event import CalendarEvent
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -63,4 +64,5 @@ __all__ = [
|
||||
"Permission",
|
||||
"Role",
|
||||
"ApiToken",
|
||||
"CalendarEvent",
|
||||
]
|
||||
|
||||
227
app/models/calendar_event.py
Normal file
227
app/models/calendar_event.py
Normal file
@@ -0,0 +1,227 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
from app.utils.timezone import now_in_app_timezone
|
||||
|
||||
|
||||
class CalendarEvent(db.Model):
|
||||
"""Calendar event model for scheduling meetings, appointments, and other events"""
|
||||
|
||||
__tablename__ = 'calendar_events'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
title = db.Column(db.String(200), nullable=False)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
start_time = db.Column(db.DateTime, nullable=False, index=True)
|
||||
end_time = db.Column(db.DateTime, nullable=False, index=True)
|
||||
all_day = db.Column(db.Boolean, default=False, nullable=False)
|
||||
location = db.Column(db.String(200), nullable=True)
|
||||
|
||||
# Event type: meeting, appointment, reminder, deadline, or custom
|
||||
event_type = db.Column(db.String(50), default='event', nullable=False, index=True)
|
||||
|
||||
# Optional associations
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
|
||||
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True)
|
||||
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
|
||||
|
||||
# Recurring event support
|
||||
is_recurring = db.Column(db.Boolean, default=False, nullable=False)
|
||||
recurrence_rule = db.Column(db.String(200), nullable=True) # RRULE format (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR")
|
||||
recurrence_end_date = db.Column(db.DateTime, nullable=True)
|
||||
parent_event_id = db.Column(db.Integer, db.ForeignKey('calendar_events.id'), nullable=True, index=True)
|
||||
|
||||
# Reminders
|
||||
reminder_minutes = db.Column(db.Integer, nullable=True) # Minutes before event to remind
|
||||
|
||||
# Color coding
|
||||
color = db.Column(db.String(7), nullable=True) # Hex color code (e.g., #FF5733)
|
||||
|
||||
# Privacy
|
||||
is_private = db.Column(db.Boolean, default=False, nullable=False)
|
||||
|
||||
created_at = db.Column(db.DateTime, default=now_in_app_timezone, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=now_in_app_timezone, onupdate=now_in_app_timezone, nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = db.relationship('User', backref=db.backref('calendar_events', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
project = db.relationship('Project', backref=db.backref('calendar_events', lazy='dynamic'))
|
||||
task = db.relationship('Task', backref=db.backref('calendar_events', lazy='dynamic'))
|
||||
client = db.relationship('Client', backref=db.backref('calendar_events', lazy='dynamic'))
|
||||
|
||||
# For recurring events - parent/child relationship
|
||||
child_events = db.relationship(
|
||||
'CalendarEvent',
|
||||
backref=db.backref('parent_event', remote_side=[id]),
|
||||
foreign_keys=[parent_event_id],
|
||||
lazy='dynamic',
|
||||
cascade='all, delete-orphan'
|
||||
)
|
||||
|
||||
def __init__(self, user_id, title, start_time, end_time, **kwargs):
|
||||
"""Initialize a CalendarEvent instance.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user who created this event
|
||||
title: Title of the event
|
||||
start_time: Start datetime of the event
|
||||
end_time: End datetime of the event
|
||||
**kwargs: Additional optional fields
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.title = title
|
||||
self.start_time = start_time
|
||||
self.end_time = end_time
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CalendarEvent {self.title} ({self.start_time})>'
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert event to dictionary for API responses"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title,
|
||||
'description': self.description,
|
||||
'start': self.start_time.isoformat() if self.start_time else None,
|
||||
'end': self.end_time.isoformat() if self.end_time else None,
|
||||
'allDay': self.all_day,
|
||||
'location': self.location,
|
||||
'eventType': self.event_type,
|
||||
'projectId': self.project_id,
|
||||
'taskId': self.task_id,
|
||||
'clientId': self.client_id,
|
||||
'isRecurring': self.is_recurring,
|
||||
'recurrenceRule': self.recurrence_rule,
|
||||
'recurrenceEndDate': self.recurrence_end_date.isoformat() if self.recurrence_end_date else None,
|
||||
'parentEventId': self.parent_event_id,
|
||||
'reminderMinutes': self.reminder_minutes,
|
||||
'color': self.color,
|
||||
'isPrivate': self.is_private,
|
||||
'createdAt': self.created_at.isoformat() if self.created_at else None,
|
||||
'updatedAt': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
def duration_hours(self):
|
||||
"""Calculate duration of event in hours"""
|
||||
if self.start_time and self.end_time:
|
||||
delta = self.end_time - self.start_time
|
||||
return delta.total_seconds() / 3600
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def get_events_in_range(user_id, start_date, end_date, include_tasks=False, include_time_entries=False):
|
||||
"""Get all events for a user within a date range.
|
||||
|
||||
Args:
|
||||
user_id: ID of the user
|
||||
start_date: Start of date range
|
||||
end_date: End of date range
|
||||
include_tasks: Whether to include tasks with due dates
|
||||
include_time_entries: Whether to include time entries
|
||||
|
||||
Returns:
|
||||
Dictionary with events, tasks, and time entries
|
||||
"""
|
||||
from app.models import Task, TimeEntry
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
print(f"\n{'*'*80}")
|
||||
print(f"MODEL - get_events_in_range called:")
|
||||
print(f" user_id={user_id}")
|
||||
print(f" start={start_date}")
|
||||
print(f" end={end_date}")
|
||||
print(f" include_tasks={include_tasks} (type: {type(include_tasks)})")
|
||||
print(f" include_time_entries={include_time_entries} (type: {type(include_time_entries)})")
|
||||
print(f"{'*'*80}\n")
|
||||
|
||||
logger.info(f"get_events_in_range called: user_id={user_id}, start={start_date}, end={end_date}, include_tasks={include_tasks}, include_time_entries={include_time_entries}")
|
||||
|
||||
result = {
|
||||
'events': [],
|
||||
'tasks': [],
|
||||
'time_entries': []
|
||||
}
|
||||
|
||||
# Get calendar events
|
||||
events = CalendarEvent.query.filter(
|
||||
CalendarEvent.user_id == user_id,
|
||||
CalendarEvent.start_time >= start_date,
|
||||
CalendarEvent.start_time <= end_date
|
||||
).order_by(CalendarEvent.start_time).all()
|
||||
|
||||
logger.info(f"Found {len(events)} calendar events")
|
||||
print(f"MODEL - Found {len(events)} calendar events")
|
||||
result['events'] = [event.to_dict() for event in events]
|
||||
|
||||
# Optionally include tasks with due dates
|
||||
if include_tasks:
|
||||
print(f"MODEL - Querying tasks for user {user_id}")
|
||||
logger.info(f"Querying tasks for user {user_id}")
|
||||
tasks = Task.query.filter(
|
||||
Task.assigned_to == user_id,
|
||||
Task.due_date.isnot(None),
|
||||
Task.due_date >= start_date.date() if hasattr(start_date, 'date') else start_date,
|
||||
Task.due_date <= end_date.date() if hasattr(end_date, 'date') else end_date,
|
||||
Task.status.in_(['todo', 'in_progress', 'review'])
|
||||
).all()
|
||||
|
||||
print(f"MODEL - Found {len(tasks)} tasks with due dates")
|
||||
logger.info(f"Found {len(tasks)} tasks with due dates")
|
||||
|
||||
result['tasks'] = [{
|
||||
'id': task.id,
|
||||
'title': task.name,
|
||||
'description': task.description,
|
||||
'dueDate': task.due_date.isoformat() if task.due_date else None,
|
||||
'status': task.status,
|
||||
'priority': task.priority,
|
||||
'projectId': task.project_id,
|
||||
'type': 'task'
|
||||
} for task in tasks]
|
||||
else:
|
||||
print(f"MODEL - Not including tasks (include_tasks=False)")
|
||||
logger.info("Not including tasks (include_tasks=False)")
|
||||
|
||||
# Optionally include time entries
|
||||
if include_time_entries:
|
||||
print(f"MODEL - Querying time entries for user {user_id}")
|
||||
logger.info(f"Querying time entries for user {user_id}")
|
||||
time_entries = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == user_id,
|
||||
TimeEntry.start_time >= start_date,
|
||||
TimeEntry.start_time <= end_date
|
||||
).order_by(TimeEntry.start_time).all()
|
||||
|
||||
print(f"MODEL - Found {len(time_entries)} time entries")
|
||||
logger.info(f"Found {len(time_entries)} time entries")
|
||||
|
||||
result['time_entries'] = [{
|
||||
'id': entry.id,
|
||||
'title': f"Time: {entry.project.name if entry.project else 'Unknown'}",
|
||||
'start': entry.start_time.isoformat() if entry.start_time else None,
|
||||
'end': entry.end_time.isoformat() if entry.end_time else None,
|
||||
'projectId': entry.project_id,
|
||||
'taskId': entry.task_id,
|
||||
'notes': entry.notes,
|
||||
'type': 'time_entry'
|
||||
} for entry in time_entries]
|
||||
else:
|
||||
print(f"MODEL - Not including time entries (include_time_entries=False)")
|
||||
logger.info("Not including time entries (include_time_entries=False)")
|
||||
|
||||
print(f"\n{'*'*80}")
|
||||
print(f"MODEL - Returning:")
|
||||
print(f" events: {len(result['events'])}")
|
||||
print(f" tasks: {len(result['tasks'])}")
|
||||
print(f" time_entries: {len(result['time_entries'])}")
|
||||
print(f"{'*'*80}\n")
|
||||
|
||||
logger.info(f"Returning: {len(result['events'])} events, {len(result['tasks'])} tasks, {len(result['time_entries'])} time_entries")
|
||||
return result
|
||||
|
||||
@@ -821,13 +821,22 @@ def bulk_entries_action():
|
||||
@api_bp.route('/api/calendar/events')
|
||||
@login_required
|
||||
def calendar_events():
|
||||
"""Return calendar events for the current user in a date range with filtering and color coding."""
|
||||
"""Return calendar events, tasks, and time entries for the current user in a date range."""
|
||||
from app.models import CalendarEvent as CalendarEventModel
|
||||
|
||||
start = request.args.get('start')
|
||||
end = request.args.get('end')
|
||||
include_tasks = request.args.get('include_tasks', 'true').lower() == 'true'
|
||||
include_time_entries = request.args.get('include_time_entries', 'true').lower() == 'true'
|
||||
project_id = request.args.get('project_id', type=int)
|
||||
task_id = request.args.get('task_id', type=int)
|
||||
tags = request.args.get('tags', '').strip()
|
||||
user_id = request.args.get('user_id', type=int) if current_user.is_admin else None
|
||||
|
||||
# Get user_id from query param (admins only) or default to current user
|
||||
if current_user.is_admin and request.args.get('user_id'):
|
||||
user_id = request.args.get('user_id', type=int)
|
||||
else:
|
||||
user_id = current_user.id
|
||||
|
||||
if not (start and end):
|
||||
return jsonify({'error': 'start and end are required'}), 400
|
||||
@@ -849,26 +858,14 @@ def calendar_events():
|
||||
if not (start_dt and end_dt):
|
||||
return jsonify({'error': 'Invalid date range'}), 400
|
||||
|
||||
# Build query with filters
|
||||
q = TimeEntry.query
|
||||
if user_id and current_user.is_admin:
|
||||
q = q.filter(TimeEntry.user_id == user_id)
|
||||
else:
|
||||
q = q.filter(TimeEntry.user_id == current_user.id)
|
||||
|
||||
q = q.filter(TimeEntry.start_time < end_dt, (TimeEntry.end_time.is_(None)) | (TimeEntry.end_time > start_dt))
|
||||
|
||||
if project_id:
|
||||
q = q.filter(TimeEntry.project_id == project_id)
|
||||
if task_id:
|
||||
q = q.filter(TimeEntry.task_id == task_id)
|
||||
if tags:
|
||||
q = q.filter(TimeEntry.tags.ilike(f'%{tags}%'))
|
||||
|
||||
items = q.order_by(TimeEntry.start_time.asc()).all()
|
||||
|
||||
events = []
|
||||
now_local = local_now()
|
||||
# Get all calendar items using the new method
|
||||
result = CalendarEventModel.get_events_in_range(
|
||||
user_id=user_id,
|
||||
start_date=start_dt,
|
||||
end_date=end_dt,
|
||||
include_tasks=include_tasks,
|
||||
include_time_entries=include_time_entries
|
||||
)
|
||||
|
||||
# Color scheme for projects (deterministic based on project ID)
|
||||
def get_project_color(project_id):
|
||||
@@ -876,44 +873,81 @@ def calendar_events():
|
||||
'#3b82f6', '#ef4444', '#10b981', '#f59e0b', '#8b5cf6',
|
||||
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
|
||||
]
|
||||
return colors[project_id % len(colors)]
|
||||
return colors[project_id % len(colors)] if project_id else '#6b7280'
|
||||
|
||||
for e in items:
|
||||
# Build detailed title
|
||||
title_parts = []
|
||||
if e.project:
|
||||
title_parts.append(e.project.name)
|
||||
if e.task:
|
||||
title_parts.append(f"• {e.task.name}")
|
||||
elif e.notes:
|
||||
note_preview = e.notes[:30] + ('...' if len(e.notes) > 30 else '')
|
||||
title_parts.append(f"• {note_preview}")
|
||||
# Apply filters and format time entries
|
||||
time_entries = []
|
||||
for e in result.get('time_entries', []):
|
||||
# Apply filters
|
||||
if project_id and e.get('projectId') != project_id:
|
||||
continue
|
||||
if task_id and e.get('taskId') != task_id:
|
||||
continue
|
||||
if tags and tags.lower() not in (e.get('notes') or '').lower():
|
||||
continue
|
||||
|
||||
ev = {
|
||||
'id': e.id,
|
||||
'title': ' '.join(title_parts) if title_parts else 'Time Entry',
|
||||
'start': e.start_time.isoformat(),
|
||||
'end': (e.end_time or now_local).isoformat(),
|
||||
time_entries.append({
|
||||
'id': e['id'],
|
||||
'title': e['title'],
|
||||
'start': e['start'],
|
||||
'end': e['end'],
|
||||
'editable': True,
|
||||
'allDay': False,
|
||||
'backgroundColor': get_project_color(e.project_id) if e.project_id else '#6b7280',
|
||||
'borderColor': get_project_color(e.project_id) if e.project_id else '#6b7280',
|
||||
'backgroundColor': get_project_color(e.get('projectId')),
|
||||
'borderColor': get_project_color(e.get('projectId')),
|
||||
'extendedProps': {
|
||||
'project_id': e.project_id,
|
||||
'project_name': e.project.name if e.project else None,
|
||||
'task_id': e.task_id,
|
||||
'task_name': e.task.name if e.task else None,
|
||||
'notes': e.notes,
|
||||
'tags': e.tags,
|
||||
'billable': e.billable,
|
||||
'duration_hours': e.duration_hours,
|
||||
'user_id': e.user_id,
|
||||
'source': e.source
|
||||
**e,
|
||||
'item_type': 'time_entry'
|
||||
}
|
||||
})
|
||||
|
||||
# Format tasks
|
||||
tasks = []
|
||||
for t in result.get('tasks', []):
|
||||
tasks.append({
|
||||
'id': t['id'],
|
||||
'title': t['title'],
|
||||
'start': t['dueDate'],
|
||||
'end': t['dueDate'],
|
||||
'allDay': True,
|
||||
'editable': False,
|
||||
'backgroundColor': '#f59e0b',
|
||||
'borderColor': '#f59e0b',
|
||||
'extendedProps': {
|
||||
**t,
|
||||
'item_type': 'task'
|
||||
}
|
||||
})
|
||||
|
||||
# Format calendar events
|
||||
events = []
|
||||
for ev in result.get('events', []):
|
||||
events.append({
|
||||
'id': ev['id'],
|
||||
'title': ev['title'],
|
||||
'start': ev['start'],
|
||||
'end': ev['end'],
|
||||
'allDay': ev.get('allDay', False),
|
||||
'editable': True,
|
||||
'backgroundColor': ev.get('color', '#3b82f6'),
|
||||
'borderColor': ev.get('color', '#3b82f6'),
|
||||
'extendedProps': {
|
||||
**ev,
|
||||
'item_type': 'event'
|
||||
}
|
||||
})
|
||||
|
||||
# Combine all items
|
||||
all_items = events + tasks + time_entries
|
||||
|
||||
return jsonify({
|
||||
'events': all_items,
|
||||
'summary': {
|
||||
'calendar_events': len(events),
|
||||
'tasks': len(tasks),
|
||||
'time_entries': len(time_entries)
|
||||
}
|
||||
events.append(ev)
|
||||
|
||||
return jsonify({'events': events})
|
||||
})
|
||||
|
||||
@api_bp.route('/api/calendar/export')
|
||||
@login_required
|
||||
|
||||
428
app/routes/calendar.py
Normal file
428
app/routes/calendar.py
Normal file
@@ -0,0 +1,428 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from app import db
|
||||
from app.models import CalendarEvent, Task, Project, Client, TimeEntry
|
||||
from datetime import datetime, timedelta
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.timezone import now_in_app_timezone
|
||||
from app.utils.permissions import check_permission
|
||||
|
||||
calendar_bp = Blueprint('calendar', __name__)
|
||||
|
||||
|
||||
@calendar_bp.route('/calendar')
|
||||
@login_required
|
||||
def view_calendar():
|
||||
"""Display the calendar view with events, tasks, and time entries"""
|
||||
view_type = request.args.get('view', 'month') # day, week, month
|
||||
date_str = request.args.get('date', '')
|
||||
|
||||
# Parse the date or use today
|
||||
if date_str:
|
||||
try:
|
||||
current_date = datetime.strptime(date_str, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
current_date = now_in_app_timezone()
|
||||
else:
|
||||
current_date = now_in_app_timezone()
|
||||
|
||||
# Get projects and clients for event creation
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
|
||||
|
||||
return render_template(
|
||||
'calendar/view.html',
|
||||
view_type=view_type,
|
||||
current_date=current_date,
|
||||
projects=projects,
|
||||
clients=clients
|
||||
)
|
||||
|
||||
|
||||
@calendar_bp.route('/api/calendar/events')
|
||||
@login_required
|
||||
def get_events():
|
||||
"""API endpoint to fetch calendar events for a date range"""
|
||||
start_str = request.args.get('start')
|
||||
end_str = request.args.get('end')
|
||||
include_tasks = request.args.get('include_tasks', 'true').lower() == 'true'
|
||||
include_time_entries = request.args.get('include_time_entries', 'true').lower() == 'true'
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"API ENDPOINT CALLED - /api/calendar/events")
|
||||
print(f" include_tasks query param: {request.args.get('include_tasks')}")
|
||||
print(f" include_time_entries query param: {request.args.get('include_time_entries')}")
|
||||
print(f" include_tasks parsed: {include_tasks}")
|
||||
print(f" include_time_entries parsed: {include_time_entries}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
if not start_str or not end_str:
|
||||
return jsonify({'error': 'Start and end dates are required'}), 400
|
||||
|
||||
try:
|
||||
start_date = datetime.fromisoformat(start_str.replace('Z', '+00:00'))
|
||||
end_date = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
|
||||
except (ValueError, AttributeError):
|
||||
return jsonify({'error': 'Invalid date format'}), 400
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"ROUTE HANDLER - get_events API:")
|
||||
print(f" user_id={current_user.id}")
|
||||
print(f" start_date={start_date}")
|
||||
print(f" end_date={end_date}")
|
||||
print(f" include_tasks={include_tasks} (type: {type(include_tasks)})")
|
||||
print(f" include_time_entries={include_time_entries} (type: {type(include_time_entries)})")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Get events using the model's static method
|
||||
result = CalendarEvent.get_events_in_range(
|
||||
user_id=current_user.id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
include_tasks=include_tasks,
|
||||
include_time_entries=include_time_entries
|
||||
)
|
||||
|
||||
print(f"\n{'='*80}")
|
||||
print(f"ROUTE HANDLER - Result from get_events_in_range:")
|
||||
print(f" events count: {len(result.get('events', []))}")
|
||||
print(f" tasks count: {len(result.get('tasks', []))}")
|
||||
print(f" time_entries count: {len(result.get('time_entries', []))}")
|
||||
print(f"{'='*80}\n")
|
||||
|
||||
# Add debug marker to verify this code is running
|
||||
result['_debug_timestamp'] = datetime.now().isoformat()
|
||||
result['_debug_version'] = 'v3_no_cache'
|
||||
|
||||
response = jsonify(result)
|
||||
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
|
||||
@calendar_bp.route('/api/calendar/events', methods=['POST'])
|
||||
@login_required
|
||||
def create_event():
|
||||
"""Create a new calendar event"""
|
||||
data = request.get_json()
|
||||
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
# Validate required fields
|
||||
required_fields = ['title', 'start', 'end']
|
||||
for field in required_fields:
|
||||
if field not in data:
|
||||
return jsonify({'error': f'Missing required field: {field}'}), 400
|
||||
|
||||
try:
|
||||
# Parse dates
|
||||
start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
|
||||
end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
|
||||
|
||||
# Create event
|
||||
event = CalendarEvent(
|
||||
user_id=current_user.id,
|
||||
title=data['title'],
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
description=data.get('description'),
|
||||
all_day=data.get('allDay', False),
|
||||
location=data.get('location'),
|
||||
event_type=data.get('eventType', 'event'),
|
||||
project_id=data.get('projectId'),
|
||||
task_id=data.get('taskId'),
|
||||
client_id=data.get('clientId'),
|
||||
is_recurring=data.get('isRecurring', False),
|
||||
recurrence_rule=data.get('recurrenceRule'),
|
||||
recurrence_end_date=datetime.fromisoformat(data['recurrenceEndDate'].replace('Z', '+00:00')) if data.get('recurrenceEndDate') else None,
|
||||
reminder_minutes=data.get('reminderMinutes'),
|
||||
color=data.get('color'),
|
||||
is_private=data.get('isPrivate', False)
|
||||
)
|
||||
|
||||
db.session.add(event)
|
||||
if not safe_commit():
|
||||
return jsonify({'error': 'Failed to create event'}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'event': event.to_dict(),
|
||||
'message': _('Event created successfully')
|
||||
}), 201
|
||||
|
||||
except (ValueError, AttributeError) as e:
|
||||
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'Error creating event: {str(e)}'}), 500
|
||||
|
||||
|
||||
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_event(event_id):
|
||||
"""Get a specific calendar event"""
|
||||
event = CalendarEvent.query.get_or_404(event_id)
|
||||
|
||||
# Check if user has permission to view this event
|
||||
if event.user_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
return jsonify(event.to_dict())
|
||||
|
||||
|
||||
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['PUT'])
|
||||
@login_required
|
||||
def update_event(event_id):
|
||||
"""Update a calendar event"""
|
||||
event = CalendarEvent.query.get_or_404(event_id)
|
||||
|
||||
# Check if user has permission to edit this event
|
||||
if event.user_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
try:
|
||||
# Update fields
|
||||
if 'title' in data:
|
||||
event.title = data['title']
|
||||
if 'description' in data:
|
||||
event.description = data['description']
|
||||
if 'start' in data:
|
||||
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
|
||||
if 'end' in data:
|
||||
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
|
||||
if 'allDay' in data:
|
||||
event.all_day = data['allDay']
|
||||
if 'location' in data:
|
||||
event.location = data['location']
|
||||
if 'eventType' in data:
|
||||
event.event_type = data['eventType']
|
||||
if 'projectId' in data:
|
||||
event.project_id = data['projectId']
|
||||
if 'taskId' in data:
|
||||
event.task_id = data['taskId']
|
||||
if 'clientId' in data:
|
||||
event.client_id = data['clientId']
|
||||
if 'isRecurring' in data:
|
||||
event.is_recurring = data['isRecurring']
|
||||
if 'recurrenceRule' in data:
|
||||
event.recurrence_rule = data['recurrenceRule']
|
||||
if 'recurrenceEndDate' in data:
|
||||
event.recurrence_end_date = datetime.fromisoformat(data['recurrenceEndDate'].replace('Z', '+00:00')) if data['recurrenceEndDate'] else None
|
||||
if 'reminderMinutes' in data:
|
||||
event.reminder_minutes = data['reminderMinutes']
|
||||
if 'color' in data:
|
||||
event.color = data['color']
|
||||
if 'isPrivate' in data:
|
||||
event.is_private = data['isPrivate']
|
||||
|
||||
event.updated_at = now_in_app_timezone()
|
||||
|
||||
if not safe_commit():
|
||||
return jsonify({'error': 'Failed to update event'}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'event': event.to_dict(),
|
||||
'message': _('Event updated successfully')
|
||||
})
|
||||
|
||||
except (ValueError, AttributeError) as e:
|
||||
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'Error updating event: {str(e)}'}), 500
|
||||
|
||||
|
||||
@calendar_bp.route('/api/calendar/events/<int:event_id>', methods=['DELETE', 'POST'])
|
||||
@login_required
|
||||
def delete_event(event_id):
|
||||
"""Delete a calendar event"""
|
||||
event = CalendarEvent.query.get_or_404(event_id)
|
||||
|
||||
# Check if user has permission to delete this event
|
||||
if event.user_id != current_user.id and not current_user.is_admin:
|
||||
if request.method == 'POST':
|
||||
flash(_('You do not have permission to delete this event.'), 'error')
|
||||
return redirect(url_for('calendar.view_calendar'))
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
try:
|
||||
db.session.delete(event)
|
||||
if not safe_commit():
|
||||
if request.method == 'POST':
|
||||
flash(_('Failed to delete event'), 'error')
|
||||
return redirect(url_for('calendar.view_calendar'))
|
||||
return jsonify({'error': 'Failed to delete event'}), 500
|
||||
|
||||
if request.method == 'POST':
|
||||
flash(_('Event deleted successfully'), 'success')
|
||||
return redirect(url_for('calendar.view_calendar'))
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': _('Event deleted successfully')
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
if request.method == 'POST':
|
||||
flash(_('Error deleting event: %(error)s', error=str(e)), 'error')
|
||||
return redirect(url_for('calendar.view_calendar'))
|
||||
return jsonify({'error': f'Error deleting event: {str(e)}'}), 500
|
||||
|
||||
|
||||
@calendar_bp.route('/api/calendar/events/<int:event_id>/move', methods=['POST'])
|
||||
@login_required
|
||||
def move_event(event_id):
|
||||
"""Move an event to a new time (drag and drop support)"""
|
||||
event = CalendarEvent.query.get_or_404(event_id)
|
||||
|
||||
# Check if user has permission to edit this event
|
||||
if event.user_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
if not data or 'start' not in data or 'end' not in data:
|
||||
return jsonify({'error': 'Start and end times are required'}), 400
|
||||
|
||||
try:
|
||||
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
|
||||
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
|
||||
event.updated_at = now_in_app_timezone()
|
||||
|
||||
if not safe_commit():
|
||||
return jsonify({'error': 'Failed to move event'}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'event': event.to_dict(),
|
||||
'message': _('Event moved successfully')
|
||||
})
|
||||
|
||||
except (ValueError, AttributeError) as e:
|
||||
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'Error moving event: {str(e)}'}), 500
|
||||
|
||||
|
||||
@calendar_bp.route('/api/calendar/events/<int:event_id>/resize', methods=['POST'])
|
||||
@login_required
|
||||
def resize_event(event_id):
|
||||
"""Resize an event (change duration)"""
|
||||
event = CalendarEvent.query.get_or_404(event_id)
|
||||
|
||||
# Check if user has permission to edit this event
|
||||
if event.user_id != current_user.id and not current_user.is_admin:
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'error': 'No data provided'}), 400
|
||||
|
||||
try:
|
||||
if 'end' in data:
|
||||
event.end_time = datetime.fromisoformat(data['end'].replace('Z', '+00:00'))
|
||||
elif 'start' in data:
|
||||
event.start_time = datetime.fromisoformat(data['start'].replace('Z', '+00:00'))
|
||||
|
||||
event.updated_at = now_in_app_timezone()
|
||||
|
||||
if not safe_commit():
|
||||
return jsonify({'error': 'Failed to resize event'}), 500
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'event': event.to_dict(),
|
||||
'message': _('Event resized successfully')
|
||||
})
|
||||
|
||||
except (ValueError, AttributeError) as e:
|
||||
return jsonify({'error': f'Invalid data: {str(e)}'}), 400
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({'error': f'Error resizing event: {str(e)}'}), 500
|
||||
|
||||
|
||||
@calendar_bp.route('/calendar/event/<int:event_id>')
|
||||
@login_required
|
||||
def view_event(event_id):
|
||||
"""View event details page"""
|
||||
event = CalendarEvent.query.get_or_404(event_id)
|
||||
|
||||
# Check if user has permission to view this event
|
||||
if event.user_id != current_user.id and not current_user.is_admin:
|
||||
flash(_('You do not have permission to view this event.'), 'error')
|
||||
return redirect(url_for('calendar.view_calendar'))
|
||||
|
||||
return render_template('calendar/event_detail.html', event=event)
|
||||
|
||||
|
||||
@calendar_bp.route('/calendar/event/new')
|
||||
@login_required
|
||||
def new_event():
|
||||
"""Create new event form"""
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
|
||||
tasks = Task.query.filter_by(assigned_to=current_user.id, status='in_progress').order_by(Task.name).all()
|
||||
|
||||
# Get date from query params if provided
|
||||
date_str = request.args.get('date')
|
||||
time_str = request.args.get('time')
|
||||
|
||||
initial_date = None
|
||||
initial_time = None
|
||||
|
||||
if date_str:
|
||||
try:
|
||||
initial_date = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if time_str:
|
||||
try:
|
||||
initial_time = datetime.strptime(time_str, '%H:%M').time()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return render_template(
|
||||
'calendar/event_form.html',
|
||||
projects=projects,
|
||||
clients=clients,
|
||||
tasks=tasks,
|
||||
initial_date=initial_date,
|
||||
initial_time=initial_time
|
||||
)
|
||||
|
||||
|
||||
@calendar_bp.route('/calendar/event/<int:event_id>/edit')
|
||||
@login_required
|
||||
def edit_event(event_id):
|
||||
"""Edit event form"""
|
||||
event = CalendarEvent.query.get_or_404(event_id)
|
||||
|
||||
# Check if user has permission to edit this event
|
||||
if event.user_id != current_user.id and not current_user.is_admin:
|
||||
flash(_('You do not have permission to edit this event.'), 'error')
|
||||
return redirect(url_for('calendar.view_calendar'))
|
||||
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
clients = Client.query.filter_by(is_active=True).order_by(Client.name).all()
|
||||
tasks = Task.query.filter_by(assigned_to=current_user.id).order_by(Task.name).all()
|
||||
|
||||
return render_template(
|
||||
'calendar/event_form.html',
|
||||
event=event,
|
||||
projects=projects,
|
||||
clients=clients,
|
||||
tasks=tasks,
|
||||
edit_mode=True
|
||||
)
|
||||
|
||||
472
app/static/calendar.css
Normal file
472
app/static/calendar.css
Normal file
@@ -0,0 +1,472 @@
|
||||
/* Calendar Styles for TimeTracker */
|
||||
|
||||
.calendar-container {
|
||||
min-height: 600px;
|
||||
}
|
||||
|
||||
/* Day View */
|
||||
.calendar-day-view {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.time-slots {
|
||||
border-right: 2px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
height: 60px;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
}
|
||||
|
||||
.events-column {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.day-events-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 4px solid;
|
||||
background-color: var(--card-bg, #ffffff);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.event-card.event {
|
||||
border-left-color: #3b82f6;
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.event-card.task {
|
||||
border-left-color: #f59e0b;
|
||||
background-color: #fffbeb;
|
||||
}
|
||||
|
||||
.event-card.time_entry {
|
||||
border-left-color: #10b981;
|
||||
background-color: #ecfdf5;
|
||||
opacity: 0.9;
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.event-card.time_entry::before {
|
||||
content: "⏱ ";
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.event-card.task::before {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.dark .event-card {
|
||||
background-color: var(--card-dark-bg, #1e293b);
|
||||
}
|
||||
|
||||
.dark .event-card.event {
|
||||
background-color: #1e3a8a;
|
||||
}
|
||||
|
||||
.dark .event-card.task {
|
||||
background-color: #78350f;
|
||||
}
|
||||
|
||||
.dark .event-card.time_entry {
|
||||
background-color: #064e3b;
|
||||
}
|
||||
|
||||
/* Week View */
|
||||
.calendar-week-view {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.week-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.week-table th {
|
||||
padding: 1rem;
|
||||
background-color: var(--header-bg, #f9fafb);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.week-table th.today {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.dark .week-table th {
|
||||
background-color: var(--header-dark-bg, #1e293b);
|
||||
}
|
||||
|
||||
.dark .week-table th.today {
|
||||
background-color: #1e3a8a;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.week-cell {
|
||||
height: 60px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
padding: 0.25rem;
|
||||
vertical-align: top;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.week-cell:hover {
|
||||
background-color: var(--hover-bg, #f9fafb);
|
||||
}
|
||||
|
||||
.dark .week-cell:hover {
|
||||
background-color: var(--hover-dark-bg, #334155);
|
||||
}
|
||||
|
||||
.event-chip {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.event-chip:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.event-chip.time-entry-chip {
|
||||
background-color: #10b981 !important;
|
||||
cursor: default !important;
|
||||
opacity: 0.8 !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.event-chip.task-chip {
|
||||
background-color: #f59e0b !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Month View */
|
||||
.calendar-month-view {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.month-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.month-table th {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--header-bg, #f9fafb);
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dark .month-table th {
|
||||
background-color: var(--header-dark-bg, #1e293b);
|
||||
}
|
||||
|
||||
.month-cell {
|
||||
height: 120px;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
padding: 0.5rem;
|
||||
vertical-align: top;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.month-cell:hover {
|
||||
background-color: var(--hover-bg, #f9fafb);
|
||||
}
|
||||
|
||||
.dark .month-cell:hover {
|
||||
background-color: var(--hover-dark-bg, #334155);
|
||||
}
|
||||
|
||||
.month-cell.today {
|
||||
background-color: #dbeafe;
|
||||
}
|
||||
|
||||
.dark .month-cell.today {
|
||||
background-color: #1e3a8a;
|
||||
}
|
||||
|
||||
.month-cell.other-month {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.date-number {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.month-cell.today .date-number {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.month-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.event-badge:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.event-badge.task-badge {
|
||||
background-color: #f59e0b;
|
||||
}
|
||||
|
||||
.event-badge.time-entry-badge {
|
||||
background-color: #10b981;
|
||||
cursor: default;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.event-badge-more {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-muted, #6b7280);
|
||||
font-weight: 600;
|
||||
margin-top: 0.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-dialog {
|
||||
background-color: var(--card-bg, #ffffff);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dark .modal-dialog {
|
||||
background-color: var(--card-dark-bg, #1e293b);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e2e8f0);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem;
|
||||
border-top: 1px solid var(--border-color, #e2e8f0);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted, #6b7280);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text-color, #111827);
|
||||
}
|
||||
|
||||
.dark .close:hover {
|
||||
color: var(--text-dark-color, #f9fafb);
|
||||
}
|
||||
|
||||
/* Button Group */
|
||||
.btn-group {
|
||||
display: inline-flex;
|
||||
border-radius: 0.375rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
border-radius: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn-group .btn:first-child {
|
||||
border-top-left-radius: 0.375rem;
|
||||
border-bottom-left-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.btn-group .btn:last-child {
|
||||
border-top-right-radius: 0.375rem;
|
||||
border-bottom-right-radius: 0.375rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.calendar-day-view {
|
||||
grid-template-columns: 60px 1fr;
|
||||
}
|
||||
|
||||
.time-slot {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.month-cell {
|
||||
height: 80px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.week-table {
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
.event-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.dark {
|
||||
--border-color: #374151;
|
||||
--header-bg: #1e293b;
|
||||
--header-dark-bg: #0f172a;
|
||||
--card-bg: #1e293b;
|
||||
--card-dark-bg: #0f172a;
|
||||
--hover-bg: #334155;
|
||||
--hover-dark-bg: #1e293b;
|
||||
--text-muted: #9ca3af;
|
||||
--text-color: #f9fafb;
|
||||
--text-dark-color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.calendar-container .text-center {
|
||||
padding: 3rem;
|
||||
}
|
||||
|
||||
/* Badge styles */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
background-color: #6b7280;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Form styles for calendar forms */
|
||||
.form-label.required::after {
|
||||
content: ' *';
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e2e8f0);
|
||||
border-radius: 0.375rem;
|
||||
background-color: var(--input-bg, #ffffff);
|
||||
color: var(--text-color, #111827);
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.dark .form-control {
|
||||
background-color: var(--input-dark-bg, #0f172a);
|
||||
color: var(--text-dark-color, #f9fafb);
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
529
app/static/calendar.js
Normal file
529
app/static/calendar.js
Normal file
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* Calendar functionality for TimeTracker
|
||||
* Handles day, week, and month views with drag-and-drop support
|
||||
*/
|
||||
|
||||
class Calendar {
|
||||
constructor(options) {
|
||||
this.viewType = options.viewType || 'month';
|
||||
this.currentDate = new Date(options.currentDate || new Date());
|
||||
this.container = document.getElementById('calendarContainer');
|
||||
this.apiUrl = options.apiUrl;
|
||||
this.events = [];
|
||||
this.tasks = [];
|
||||
this.timeEntries = [];
|
||||
|
||||
// Filters
|
||||
this.showEvents = true;
|
||||
this.showTasks = true;
|
||||
this.showTimeEntries = true;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupEventListeners();
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// View navigation
|
||||
document.getElementById('todayBtn')?.addEventListener('click', () => {
|
||||
this.currentDate = new Date();
|
||||
this.loadEvents();
|
||||
});
|
||||
|
||||
document.getElementById('prevBtn')?.addEventListener('click', () => {
|
||||
this.navigatePrevious();
|
||||
});
|
||||
|
||||
document.getElementById('nextBtn')?.addEventListener('click', () => {
|
||||
this.navigateNext();
|
||||
});
|
||||
|
||||
// Filters
|
||||
document.getElementById('showEvents')?.addEventListener('change', (e) => {
|
||||
this.showEvents = e.target.checked;
|
||||
this.render();
|
||||
});
|
||||
|
||||
document.getElementById('showTasks')?.addEventListener('change', (e) => {
|
||||
this.showTasks = e.target.checked;
|
||||
this.render();
|
||||
});
|
||||
|
||||
document.getElementById('showTimeEntries')?.addEventListener('change', (e) => {
|
||||
this.showTimeEntries = e.target.checked;
|
||||
this.render();
|
||||
});
|
||||
|
||||
// Modal close
|
||||
document.querySelectorAll('[data-dismiss="modal"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.getElementById('eventModal').style.display = 'none';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
navigatePrevious() {
|
||||
switch (this.viewType) {
|
||||
case 'day':
|
||||
this.currentDate.setDate(this.currentDate.getDate() - 1);
|
||||
break;
|
||||
case 'week':
|
||||
this.currentDate.setDate(this.currentDate.getDate() - 7);
|
||||
break;
|
||||
case 'month':
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||
break;
|
||||
}
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
navigateNext() {
|
||||
switch (this.viewType) {
|
||||
case 'day':
|
||||
this.currentDate.setDate(this.currentDate.getDate() + 1);
|
||||
break;
|
||||
case 'week':
|
||||
this.currentDate.setDate(this.currentDate.getDate() + 7);
|
||||
break;
|
||||
case 'month':
|
||||
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||
break;
|
||||
}
|
||||
this.loadEvents();
|
||||
}
|
||||
|
||||
async loadEvents() {
|
||||
const { start, end } = this.getDateRange();
|
||||
|
||||
try {
|
||||
const url = new URL(this.apiUrl, window.location.origin);
|
||||
url.searchParams.append('start', start.toISOString());
|
||||
url.searchParams.append('end', end.toISOString());
|
||||
url.searchParams.append('include_tasks', 'true');
|
||||
url.searchParams.append('include_time_entries', 'true');
|
||||
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
|
||||
// Parse items by type (all items come in the 'events' array with item_type in extendedProps)
|
||||
const allItems = data.events || [];
|
||||
this.events = allItems.filter(item => item.extendedProps?.item_type === 'event');
|
||||
this.tasks = allItems.filter(item => item.extendedProps?.item_type === 'task');
|
||||
this.timeEntries = allItems.filter(item => item.extendedProps?.item_type === 'time_entry');
|
||||
|
||||
console.log('API Response:', {
|
||||
total: allItems.length,
|
||||
events: this.events.length,
|
||||
tasks: this.tasks.length,
|
||||
time_entries: this.timeEntries.length,
|
||||
summary: data.summary,
|
||||
rawData: data
|
||||
});
|
||||
|
||||
this.render();
|
||||
} catch (error) {
|
||||
console.error('Error loading events:', error);
|
||||
this.container.innerHTML = '<div class="text-center text-red-500 py-12">Error loading calendar data</div>';
|
||||
}
|
||||
}
|
||||
|
||||
getDateRange() {
|
||||
let start, end;
|
||||
|
||||
switch (this.viewType) {
|
||||
case 'day':
|
||||
start = new Date(this.currentDate);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end = new Date(this.currentDate);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
|
||||
case 'week':
|
||||
const day = this.currentDate.getDay();
|
||||
const diff = this.currentDate.getDate() - day + (day === 0 ? -6 : 1); // Monday as start
|
||||
start = new Date(this.currentDate);
|
||||
start.setDate(diff);
|
||||
start.setHours(0, 0, 0, 0);
|
||||
end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
break;
|
||||
|
||||
case 'month':
|
||||
start = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
|
||||
end = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
break;
|
||||
}
|
||||
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
render() {
|
||||
this.updateTitle();
|
||||
|
||||
console.log('Calendar rendering:', {
|
||||
viewType: this.viewType,
|
||||
eventsCount: this.events.length,
|
||||
tasksCount: this.tasks.length,
|
||||
timeEntriesCount: this.timeEntries.length,
|
||||
showEvents: this.showEvents,
|
||||
showTasks: this.showTasks,
|
||||
showTimeEntries: this.showTimeEntries
|
||||
});
|
||||
|
||||
switch (this.viewType) {
|
||||
case 'day':
|
||||
this.renderDayView();
|
||||
break;
|
||||
case 'week':
|
||||
this.renderWeekView();
|
||||
break;
|
||||
case 'month':
|
||||
this.renderMonthView();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateTitle() {
|
||||
const titleEl = document.getElementById('calendarTitle');
|
||||
if (!titleEl) return;
|
||||
|
||||
const options = { month: 'long', year: 'numeric' };
|
||||
|
||||
switch (this.viewType) {
|
||||
case 'day':
|
||||
titleEl.textContent = this.currentDate.toLocaleDateString(undefined, {
|
||||
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric'
|
||||
});
|
||||
break;
|
||||
case 'week':
|
||||
const { start, end } = this.getDateRange();
|
||||
titleEl.textContent = `${start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} - ${end.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}`;
|
||||
break;
|
||||
case 'month':
|
||||
titleEl.textContent = this.currentDate.toLocaleDateString(undefined, options);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
renderDayView() {
|
||||
const html = `
|
||||
<div class="calendar-day-view">
|
||||
<div class="time-slots">
|
||||
${this.renderTimeSlots()}
|
||||
</div>
|
||||
<div class="events-column">
|
||||
${this.renderDayEvents()}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.container.innerHTML = html;
|
||||
}
|
||||
|
||||
renderTimeSlots() {
|
||||
const slots = [];
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
const time = `${hour.toString().padStart(2, '0')}:00`;
|
||||
slots.push(`<div class="time-slot" data-hour="${hour}">${time}</div>`);
|
||||
}
|
||||
return slots.join('');
|
||||
}
|
||||
|
||||
renderDayEvents() {
|
||||
const dayStart = new Date(this.currentDate);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(this.currentDate);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
let html = '<div class="day-events-container">';
|
||||
|
||||
// Render events
|
||||
if (this.showEvents) {
|
||||
this.events.forEach(event => {
|
||||
const eventStart = new Date(event.start);
|
||||
if (eventStart >= dayStart && eventStart <= dayEnd) {
|
||||
const time = eventStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
const eventTitle = this.escapeHtml(event.title);
|
||||
const eventColor = event.color || '#3b82f6';
|
||||
html += `
|
||||
<div class="event-card event" data-id="${event.id}" data-type="event" style="border-left-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, 'event')">
|
||||
<i class="fas fa-calendar mr-2"></i>
|
||||
<strong>${eventTitle}</strong>
|
||||
<br><small>${time}</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Render tasks
|
||||
if (this.showTasks) {
|
||||
this.tasks.forEach(task => {
|
||||
const taskTitle = this.escapeHtml(task.title);
|
||||
const priorityIcons = { urgent: '🔴', high: '🟠', medium: '🟡', low: '🟢' };
|
||||
const priorityIcon = priorityIcons[task.extendedProps?.priority] || '📋';
|
||||
html += `
|
||||
<div class="event-card task" data-id="${task.id}" data-type="task" onclick="window.open('/tasks/${task.id}', '_blank')">
|
||||
${priorityIcon} <strong>${taskTitle}</strong>
|
||||
<br><small>Due: ${task.start}</small>
|
||||
<br><small class="text-xs">Status: ${task.extendedProps?.status || 'Unknown'}</small>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
// Render time entries
|
||||
if (this.showTimeEntries) {
|
||||
this.timeEntries.forEach(entry => {
|
||||
const entryStart = new Date(entry.start);
|
||||
if (entryStart >= dayStart && entryStart <= dayEnd) {
|
||||
const startTime = entryStart.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
const entryTitle = this.escapeHtml(entry.title);
|
||||
const notes = entry.notes ? `<br><small class="text-xs">${this.escapeHtml(entry.notes)}</small>` : '';
|
||||
html += `
|
||||
<div class="event-card time_entry" data-id="${entry.id}" data-type="time_entry">
|
||||
⏱ <strong>${entryTitle}</strong>
|
||||
<br><small>${startTime}</small>
|
||||
${notes}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
renderWeekView() {
|
||||
const { start } = this.getDateRange();
|
||||
const days = [];
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const day = new Date(start);
|
||||
day.setDate(start.getDate() + i);
|
||||
days.push(day);
|
||||
}
|
||||
|
||||
let html = '<div class="calendar-week-view"><table class="week-table"><thead><tr>';
|
||||
|
||||
days.forEach(day => {
|
||||
const isToday = this.isToday(day);
|
||||
html += `<th class="${isToday ? 'today' : ''}">${day.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })}</th>`;
|
||||
});
|
||||
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
// Time slots for each day
|
||||
for (let hour = 0; hour < 24; hour++) {
|
||||
html += '<tr>';
|
||||
days.forEach(day => {
|
||||
html += `<td class="week-cell" data-date="${day.toISOString()}" data-hour="${hour}">`;
|
||||
html += this.renderWeekCellEvents(day, hour);
|
||||
html += '</td>';
|
||||
});
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
this.container.innerHTML = html;
|
||||
}
|
||||
|
||||
renderWeekCellEvents(day, hour) {
|
||||
const cellStart = new Date(day);
|
||||
cellStart.setHours(hour, 0, 0, 0);
|
||||
const cellEnd = new Date(day);
|
||||
cellEnd.setHours(hour + 1, 0, 0, 0);
|
||||
|
||||
let html = '';
|
||||
|
||||
// Check events
|
||||
if (this.showEvents) {
|
||||
this.events.forEach(event => {
|
||||
const eventStart = new Date(event.start);
|
||||
if (eventStart >= cellStart && eventStart < cellEnd) {
|
||||
const eventTitle = this.escapeHtml(event.title);
|
||||
const eventColor = event.color || '#3b82f6';
|
||||
html += `<div class="event-chip" style="background-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, 'event')" title="${eventTitle}">📅 ${eventTitle}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check tasks (only if they're due this hour)
|
||||
if (this.showTasks) {
|
||||
this.tasks.forEach(task => {
|
||||
const taskDate = new Date(task.start);
|
||||
// Show task if it's due on this day and hour 9 (morning)
|
||||
if (taskDate.toDateString() === day.toDateString() && hour === 9) {
|
||||
const taskTitle = this.escapeHtml(task.title);
|
||||
html += `<div class="event-chip task-chip" style="background-color: #f59e0b" onclick="window.open('/tasks/${task.id}', '_blank'); event.stopPropagation();" title="${taskTitle}">📋 ${taskTitle}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check time entries
|
||||
if (this.showTimeEntries) {
|
||||
this.timeEntries.forEach(entry => {
|
||||
const entryStart = new Date(entry.start);
|
||||
if (entryStart >= cellStart && entryStart < cellEnd) {
|
||||
const entryTitle = this.escapeHtml(entry.title);
|
||||
html += `<div class="event-chip time-entry-chip" style="background-color: #10b981; opacity: 0.8; cursor: default;" onclick="event.stopPropagation();" title="${entryTitle}">⏱ ${entryTitle}</div>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
renderMonthView() {
|
||||
const year = this.currentDate.getFullYear();
|
||||
const month = this.currentDate.getMonth();
|
||||
const firstDay = new Date(year, month, 1);
|
||||
const lastDay = new Date(year, month + 1, 0);
|
||||
const startDate = new Date(firstDay);
|
||||
startDate.setDate(startDate.getDate() - (startDate.getDay() === 0 ? 6 : startDate.getDay() - 1));
|
||||
|
||||
let html = '<div class="calendar-month-view"><table class="month-table"><thead><tr>';
|
||||
const weekdays = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||||
weekdays.forEach(day => {
|
||||
html += `<th>${day}</th>`;
|
||||
});
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
const currentDate = new Date(startDate);
|
||||
for (let week = 0; week < 6; week++) {
|
||||
html += '<tr>';
|
||||
for (let day = 0; day < 7; day++) {
|
||||
const isCurrentMonth = currentDate.getMonth() === month;
|
||||
const isToday = this.isToday(currentDate);
|
||||
html += `<td class="month-cell ${!isCurrentMonth ? 'other-month' : ''} ${isToday ? 'today' : ''}" data-date="${currentDate.toISOString()}">`;
|
||||
html += `<div class="date-number">${currentDate.getDate()}</div>`;
|
||||
html += this.renderMonthCellEvents(currentDate);
|
||||
html += '</td>';
|
||||
currentDate.setDate(currentDate.getDate() + 1);
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
this.container.innerHTML = html;
|
||||
|
||||
// Add click handlers for cells
|
||||
this.container.querySelectorAll('.month-cell').forEach(cell => {
|
||||
cell.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('month-cell') || e.target.classList.contains('date-number')) {
|
||||
const date = new Date(cell.dataset.date);
|
||||
window.location.href = `${window.calendarData.newEventUrl}?date=${date.toISOString().split('T')[0]}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderMonthCellEvents(day) {
|
||||
const dayStart = new Date(day);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(day);
|
||||
dayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
let html = '<div class="month-events">';
|
||||
let count = 0;
|
||||
const maxDisplay = 3;
|
||||
|
||||
// Events
|
||||
if (this.showEvents) {
|
||||
this.events.forEach(event => {
|
||||
const eventStart = new Date(event.start);
|
||||
if (eventStart >= dayStart && eventStart <= dayEnd) {
|
||||
if (count < maxDisplay) {
|
||||
const eventTitle = this.escapeHtml(event.title);
|
||||
const eventColor = event.color || '#3b82f6';
|
||||
html += `<div class="event-badge" style="background-color: ${eventColor}" onclick="window.calendar.showEventDetails(${event.id}, 'event'); event.stopPropagation();" title="${eventTitle}">📅 ${eventTitle}</div>`;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Tasks
|
||||
if (this.showTasks) {
|
||||
this.tasks.forEach(task => {
|
||||
const taskDate = new Date(task.start);
|
||||
if (taskDate.toDateString() === day.toDateString()) {
|
||||
if (count < maxDisplay) {
|
||||
const taskTitle = this.escapeHtml(task.title);
|
||||
html += `<div class="event-badge task-badge" onclick="window.open('/tasks/${task.id}', '_blank'); event.stopPropagation();" title="${taskTitle}">📋 ${taskTitle}</div>`;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Time entries
|
||||
if (this.showTimeEntries) {
|
||||
this.timeEntries.forEach(entry => {
|
||||
const entryStart = new Date(entry.start);
|
||||
if (entryStart >= dayStart && entryStart <= dayEnd) {
|
||||
if (count < maxDisplay) {
|
||||
const entryTitle = this.escapeHtml(entry.title);
|
||||
html += `<div class="event-badge time-entry-badge" onclick="event.stopPropagation();" title="${entryTitle}">⏱ ${entryTitle}</div>`;
|
||||
}
|
||||
count++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (count > maxDisplay) {
|
||||
html += `<div class="event-badge-more">+${count - maxDisplay} more</div>`;
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
return html;
|
||||
}
|
||||
|
||||
isToday(date) {
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
}
|
||||
|
||||
async showEventDetails(id, type) {
|
||||
// Navigate to the appropriate detail page
|
||||
if (type === 'event') {
|
||||
window.location.href = `/calendar/event/${id}`;
|
||||
} else if (type === 'task') {
|
||||
window.location.href = `/tasks/${id}`;
|
||||
} else if (type === 'time_entry') {
|
||||
// Time entries are displayed for context only - they're not clickable
|
||||
// Users can manage time entries via the Timer/Reports sections
|
||||
console.log('Time entry clicked:', id);
|
||||
}
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
return text ? text.replace(/[&<>"']/g, m => map[m]) : '';
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize calendar when DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (typeof window.calendarData !== 'undefined') {
|
||||
window.calendar = new Calendar({
|
||||
viewType: window.calendarData.viewType,
|
||||
currentDate: window.calendarData.currentDate,
|
||||
apiUrl: window.calendarData.apiUrl
|
||||
});
|
||||
|
||||
}
|
||||
});
|
||||
|
||||
@@ -122,6 +122,12 @@
|
||||
<span class="ml-3 sidebar-label">{{ _('Weekly Goals') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-2">
|
||||
<a href="{{ url_for('calendar.view_calendar') }}" class="flex items-center p-2 rounded-lg {% if ep.startswith('calendar.') %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-calendar-alt w-6 text-center"></i>
|
||||
<span class="ml-3 sidebar-label">{{ _('Calendar') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-2">
|
||||
<button onclick="toggleDropdown('workDropdown')" data-dropdown="workDropdown" class="w-full flex items-center p-2 rounded-lg {% if work_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-briefcase w-6 text-center"></i>
|
||||
|
||||
198
app/templates/calendar/event_detail.html
Normal file
198
app/templates/calendar/event_detail.html
Normal file
@@ -0,0 +1,198 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import confirm_dialog %}
|
||||
{% block title %}{{ event.title }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-6 max-w-4xl">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-start mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-2">{{ event.title }}</h1>
|
||||
{% if event.event_type %}
|
||||
<span class="badge badge-info">{{ event.event_type|title }}</span>
|
||||
{% endif %}
|
||||
{% if event.is_private %}
|
||||
<span class="badge badge-secondary"><i class="fas fa-lock mr-1"></i>Private</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('calendar.edit_event', event_id=event.id) }}" class="btn btn-sm btn-primary">
|
||||
<i class="fas fa-edit mr-2"></i>{{ _('Edit') }}
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-danger"
|
||||
onclick="document.getElementById('confirmDeleteEvent-{{ event.id }}').classList.remove('hidden')">
|
||||
<i class="fas fa-trash mr-2"></i>{{ _('Delete') }}
|
||||
</button>
|
||||
<form id="confirmDeleteEvent-{{ event.id }}-form" method="POST" action="{{ url_for('calendar.delete_event', event_id=event.id) }}" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="_method" value="DELETE">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Details -->
|
||||
<div class="space-y-4">
|
||||
<!-- Date and Time -->
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-clock text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Date & Time') }}</p>
|
||||
<p class="text-muted">
|
||||
{% if event.all_day %}
|
||||
{{ event.start_time.strftime('%A, %B %d, %Y') }}
|
||||
{% if event.start_time.date() != event.end_time.date() %}
|
||||
- {{ event.end_time.strftime('%A, %B %d, %Y') }}
|
||||
{% endif %}
|
||||
<span class="badge badge-secondary ml-2">All Day</span>
|
||||
{% else %}
|
||||
{{ event.start_time.strftime('%A, %B %d, %Y at %I:%M %p') }}
|
||||
- {{ event.end_time.strftime('%I:%M %p') }}
|
||||
{% if event.start_time.date() != event.end_time.date() %}
|
||||
({{ event.end_time.strftime('%B %d, %Y') }})
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
<p class="text-sm text-muted">{{ _('Duration') }}: {{ '%.2f'|format(event.duration_hours()) }} hours</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
{% if event.description %}
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-align-left text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Description') }}</p>
|
||||
<p class="text-muted whitespace-pre-wrap">{{ event.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Location -->
|
||||
{% if event.location %}
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-map-marker-alt text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Location') }}</p>
|
||||
<p class="text-muted">{{ event.location }}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Project -->
|
||||
{% if event.project %}
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-project-diagram text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Project') }}</p>
|
||||
<p class="text-muted">
|
||||
<a href="{{ url_for('projects.view_project', project_id=event.project.id) }}"
|
||||
class="text-primary hover:underline">
|
||||
{{ event.project.name }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Task -->
|
||||
{% if event.task %}
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-tasks text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Task') }}</p>
|
||||
<p class="text-muted">
|
||||
<a href="{{ url_for('tasks.view_task', task_id=event.task.id) }}"
|
||||
class="text-primary hover:underline">
|
||||
{{ event.task.name }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Client -->
|
||||
{% if event.client %}
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-user-tie text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Client') }}</p>
|
||||
<p class="text-muted">
|
||||
<a href="{{ url_for('clients.view_client', client_id=event.client.id) }}"
|
||||
class="text-primary hover:underline">
|
||||
{{ event.client.name }}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Reminder -->
|
||||
{% if event.reminder_minutes %}
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-bell text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Reminder') }}</p>
|
||||
<p class="text-muted">
|
||||
{% if event.reminder_minutes < 60 %}
|
||||
{{ event.reminder_minutes }} {{ _('minutes before') }}
|
||||
{% elif event.reminder_minutes < 1440 %}
|
||||
{{ (event.reminder_minutes / 60)|int }} {{ _('hours before') }}
|
||||
{% else %}
|
||||
{{ (event.reminder_minutes / 1440)|int }} {{ _('days before') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recurring -->
|
||||
{% if event.is_recurring %}
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-redo text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Recurring') }}</p>
|
||||
<p class="text-muted">
|
||||
{% if event.recurrence_rule %}{{ event.recurrence_rule }}{% else %}Yes{% endif %}
|
||||
{% if event.recurrence_end_date %}
|
||||
<br>{{ _('Until') }}: {{ event.recurrence_end_date.strftime('%B %d, %Y') }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Created/Updated -->
|
||||
<div class="flex items-start">
|
||||
<i class="fas fa-info-circle text-primary mt-1 mr-3 w-5"></i>
|
||||
<div>
|
||||
<p class="font-semibold">{{ _('Information') }}</p>
|
||||
<p class="text-sm text-muted">
|
||||
{{ _('Created') }}: {{ event.created_at.strftime('%B %d, %Y at %I:%M %p') }}<br>
|
||||
{{ _('Last Updated') }}: {{ event.updated_at.strftime('%B %d, %Y at %I:%M %p') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="mt-6 pt-6 border-t border-border-light dark:border-border-dark">
|
||||
<a href="{{ url_for('calendar.view_calendar') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left mr-2"></i>{{ _('Back to Calendar') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Dialog -->
|
||||
{{ confirm_dialog(
|
||||
'confirmDeleteEvent-' ~ event.id,
|
||||
_('Delete Event'),
|
||||
_('Are you sure you want to delete this event? This action cannot be undone.'),
|
||||
_('Delete'),
|
||||
_('Cancel'),
|
||||
'danger'
|
||||
) }}
|
||||
{% endblock %}
|
||||
|
||||
316
app/templates/calendar/event_form.html
Normal file
316
app/templates/calendar/event_form.html
Normal file
@@ -0,0 +1,316 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% if edit_mode %}{{ _('Edit Event') }}{% else %}{{ _('New Event') }}{% endif %} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-6 max-w-3xl">
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">
|
||||
<i class="fas fa-calendar-plus mr-2 text-primary"></i>
|
||||
{% if edit_mode %}{{ _('Edit Event') }}{% else %}{{ _('New Event') }}{% endif %}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<form id="eventForm" method="POST" action="{% if edit_mode %}{{ url_for('calendar.update_event', event_id=event.id) }}{% else %}{{ url_for('calendar.create_event') }}{% endif %}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<!-- Title -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="title" class="form-label required">{{ _('Title') }}</label>
|
||||
<input type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
class="form-control"
|
||||
required
|
||||
value="{% if event %}{{ event.title }}{% endif %}">
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="description" class="form-label">{{ _('Description') }}</label>
|
||||
<textarea id="description"
|
||||
name="description"
|
||||
class="form-control"
|
||||
rows="3">{% if event %}{{ event.description or '' }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Date and Time -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-group">
|
||||
<label for="startDate" class="form-label required">{{ _('Start Date') }}</label>
|
||||
<input type="date"
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
class="form-control"
|
||||
required
|
||||
value="{% if event %}{{ event.start_time.strftime('%Y-%m-%d') }}{% elif initial_date %}{{ initial_date.strftime('%Y-%m-%d') }}{% endif %}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="startTime" class="form-label">{{ _('Start Time') }}</label>
|
||||
<input type="time"
|
||||
id="startTime"
|
||||
name="startTime"
|
||||
class="form-control"
|
||||
value="{% if event %}{{ event.start_time.strftime('%H:%M') }}{% elif initial_time %}{{ initial_time.strftime('%H:%M') }}{% else %}09:00{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
<div class="form-group">
|
||||
<label for="endDate" class="form-label required">{{ _('End Date') }}</label>
|
||||
<input type="date"
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
class="form-control"
|
||||
required
|
||||
value="{% if event %}{{ event.end_time.strftime('%Y-%m-%d') }}{% elif initial_date %}{{ initial_date.strftime('%Y-%m-%d') }}{% endif %}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endTime" class="form-label">{{ _('End Time') }}</label>
|
||||
<input type="time"
|
||||
id="endTime"
|
||||
name="endTime"
|
||||
class="form-control"
|
||||
value="{% if event %}{{ event.end_time.strftime('%H:%M') }}{% else %}10:00{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- All Day Event -->
|
||||
<div class="form-group mb-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox"
|
||||
id="allDay"
|
||||
name="allDay"
|
||||
class="form-checkbox"
|
||||
{% if event and event.all_day %}checked{% endif %}>
|
||||
<span class="ml-2">{{ _('All Day Event') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="location" class="form-label">{{ _('Location') }}</label>
|
||||
<input type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
class="form-control"
|
||||
value="{% if event %}{{ event.location or '' }}{% endif %}">
|
||||
</div>
|
||||
|
||||
<!-- Event Type -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="eventType" class="form-label">{{ _('Event Type') }}</label>
|
||||
<select id="eventType" name="eventType" class="form-control">
|
||||
<option value="event" {% if event and event.event_type == 'event' %}selected{% endif %}>{{ _('Event') }}</option>
|
||||
<option value="meeting" {% if event and event.event_type == 'meeting' %}selected{% endif %}>{{ _('Meeting') }}</option>
|
||||
<option value="appointment" {% if event and event.event_type == 'appointment' %}selected{% endif %}>{{ _('Appointment') }}</option>
|
||||
<option value="reminder" {% if event and event.event_type == 'reminder' %}selected{% endif %}>{{ _('Reminder') }}</option>
|
||||
<option value="deadline" {% if event and event.event_type == 'deadline' %}selected{% endif %}>{{ _('Deadline') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Associated Project -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="projectId" class="form-label">{{ _('Project') }}</label>
|
||||
<select id="projectId" name="projectId" class="form-control">
|
||||
<option value="">{{ _('-- None --') }}</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" {% if event and event.project_id == project.id %}selected{% endif %}>
|
||||
{{ project.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Associated Task -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="taskId" class="form-label">{{ _('Task') }}</label>
|
||||
<select id="taskId" name="taskId" class="form-control">
|
||||
<option value="">{{ _('-- None --') }}</option>
|
||||
{% for task in tasks %}
|
||||
<option value="{{ task.id }}" {% if event and event.task_id == task.id %}selected{% endif %}>
|
||||
{{ task.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Associated Client -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="clientId" class="form-label">{{ _('Client') }}</label>
|
||||
<select id="clientId" name="clientId" class="form-control">
|
||||
<option value="">{{ _('-- None --') }}</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}" {% if event and event.client_id == client.id %}selected{% endif %}>
|
||||
{{ client.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Reminder -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="reminderMinutes" class="form-label">{{ _('Reminder') }}</label>
|
||||
<select id="reminderMinutes" name="reminderMinutes" class="form-control">
|
||||
<option value="">{{ _('No reminder') }}</option>
|
||||
<option value="5" {% if event and event.reminder_minutes == 5 %}selected{% endif %}>{{ _('5 minutes before') }}</option>
|
||||
<option value="15" {% if event and event.reminder_minutes == 15 %}selected{% endif %}>{{ _('15 minutes before') }}</option>
|
||||
<option value="30" {% if event and event.reminder_minutes == 30 %}selected{% endif %}>{{ _('30 minutes before') }}</option>
|
||||
<option value="60" {% if event and event.reminder_minutes == 60 %}selected{% endif %}>{{ _('1 hour before') }}</option>
|
||||
<option value="1440" {% if event and event.reminder_minutes == 1440 %}selected{% endif %}>{{ _('1 day before') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Color -->
|
||||
<div class="form-group mb-4">
|
||||
<label for="color" class="form-label">{{ _('Color') }}</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input type="color"
|
||||
id="color"
|
||||
name="color"
|
||||
class="form-control w-20 h-10"
|
||||
value="{% if event and event.color %}{{ event.color }}{% else %}#3b82f6{% endif %}">
|
||||
<span class="text-sm text-muted">{{ _('Choose a color for this event') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Private Event -->
|
||||
<div class="form-group mb-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox"
|
||||
id="isPrivate"
|
||||
name="isPrivate"
|
||||
class="form-checkbox"
|
||||
{% if event and event.is_private %}checked{% endif %}>
|
||||
<span class="ml-2">{{ _('Private Event') }}</span>
|
||||
</label>
|
||||
<p class="text-sm text-muted mt-1">{{ _('Private events are only visible to you') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Recurring Event Section -->
|
||||
<div class="border-t border-border-light dark:border-border-dark pt-4 mt-6 mb-4">
|
||||
<h3 class="text-lg font-semibold mb-3">{{ _('Recurring Event') }}</h3>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox"
|
||||
id="isRecurring"
|
||||
name="isRecurring"
|
||||
class="form-checkbox"
|
||||
{% if event and event.is_recurring %}checked{% endif %}>
|
||||
<span class="ml-2">{{ _('This is a recurring event') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="recurringOptions" style="{% if not event or not event.is_recurring %}display: none;{% endif %}">
|
||||
<div class="form-group mb-4">
|
||||
<label for="recurrenceRule" class="form-label">{{ _('Recurrence Pattern') }}</label>
|
||||
<input type="text"
|
||||
id="recurrenceRule"
|
||||
name="recurrenceRule"
|
||||
class="form-control"
|
||||
placeholder="FREQ=WEEKLY;BYDAY=MO,WE,FR"
|
||||
value="{% if event %}{{ event.recurrence_rule or '' }}{% endif %}">
|
||||
<p class="text-sm text-muted mt-1">{{ _('Use RRULE format (e.g., FREQ=WEEKLY;BYDAY=MO,WE,FR)') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="recurrenceEndDate" class="form-label">{{ _('Recurrence End Date') }}</label>
|
||||
<input type="date"
|
||||
id="recurrenceEndDate"
|
||||
name="recurrenceEndDate"
|
||||
class="form-control"
|
||||
value="{% if event and event.recurrence_end_date %}{{ event.recurrence_end_date.strftime('%Y-%m-%d') }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex gap-3 mt-6">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-save mr-2"></i>
|
||||
{% if edit_mode %}{{ _('Update Event') }}{% else %}{{ _('Create Event') }}{% endif %}
|
||||
</button>
|
||||
<a href="{{ url_for('calendar.view_calendar') }}" class="btn btn-secondary">
|
||||
{{ _('Cancel') }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Show/hide recurring options
|
||||
document.getElementById('isRecurring').addEventListener('change', function() {
|
||||
const recurringOptions = document.getElementById('recurringOptions');
|
||||
recurringOptions.style.display = this.checked ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// Disable time inputs when all day is checked
|
||||
document.getElementById('allDay').addEventListener('change', function() {
|
||||
const startTime = document.getElementById('startTime');
|
||||
const endTime = document.getElementById('endTime');
|
||||
startTime.disabled = this.checked;
|
||||
endTime.disabled = this.checked;
|
||||
});
|
||||
|
||||
// Form submission via AJAX
|
||||
document.getElementById('eventForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const data = {};
|
||||
|
||||
// Build event data
|
||||
const startDate = formData.get('startDate');
|
||||
const startTime = formData.get('startTime') || '00:00';
|
||||
const endDate = formData.get('endDate');
|
||||
const endTime = formData.get('endTime') || '23:59';
|
||||
|
||||
data.title = formData.get('title');
|
||||
data.description = formData.get('description') || '';
|
||||
data.start = `${startDate}T${startTime}:00`;
|
||||
data.end = `${endDate}T${endTime}:00`;
|
||||
data.allDay = formData.get('allDay') === 'on';
|
||||
data.location = formData.get('location') || '';
|
||||
data.eventType = formData.get('eventType') || 'event';
|
||||
data.projectId = formData.get('projectId') ? parseInt(formData.get('projectId')) : null;
|
||||
data.taskId = formData.get('taskId') ? parseInt(formData.get('taskId')) : null;
|
||||
data.clientId = formData.get('clientId') ? parseInt(formData.get('clientId')) : null;
|
||||
data.reminderMinutes = formData.get('reminderMinutes') ? parseInt(formData.get('reminderMinutes')) : null;
|
||||
data.color = formData.get('color') || '#3b82f6';
|
||||
data.isPrivate = formData.get('isPrivate') === 'on';
|
||||
data.isRecurring = formData.get('isRecurring') === 'on';
|
||||
data.recurrenceRule = formData.get('recurrenceRule') || '';
|
||||
data.recurrenceEndDate = formData.get('recurrenceEndDate') || null;
|
||||
|
||||
try {
|
||||
const url = this.action;
|
||||
const method = {% if edit_mode %}'PUT'{% else %}'POST'{% endif %};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': formData.get('csrf_token')
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
window.location.href = '{{ url_for('calendar.view_calendar') }}';
|
||||
} else {
|
||||
alert(result.error || 'An error occurred');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while saving the event');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
139
app/templates/calendar/view.html
Normal file
139
app/templates/calendar/view.html
Normal file
@@ -0,0 +1,139 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Calendar - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='calendar.css') }}?v=2">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Calendar Header -->
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<h1 class="text-3xl font-bold">
|
||||
<i class="fas fa-calendar-alt mr-2 text-primary"></i>
|
||||
{{ _('Calendar') }}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- View Type Selector -->
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('calendar.view_calendar', view='day', date=current_date.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm {% if view_type == 'day' %}btn-primary{% else %}btn-secondary{% endif %}">
|
||||
<i class="fas fa-calendar-day mr-1"></i> {{ _('Day') }}
|
||||
</a>
|
||||
<a href="{{ url_for('calendar.view_calendar', view='week', date=current_date.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm {% if view_type == 'week' %}btn-primary{% else %}btn-secondary{% endif %}">
|
||||
<i class="fas fa-calendar-week mr-1"></i> {{ _('Week') }}
|
||||
</a>
|
||||
<a href="{{ url_for('calendar.view_calendar', view='month', date=current_date.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-sm {% if view_type == 'month' %}btn-primary{% else %}btn-secondary{% endif %}">
|
||||
<i class="fas fa-calendar mr-1"></i> {{ _('Month') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Add Event Button -->
|
||||
<a href="{{ url_for('calendar.new_event', date=current_date.strftime('%Y-%m-%d')) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
{{ _('New Event') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Navigation -->
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
<button id="prevBtn" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<button id="todayBtn" class="btn btn-sm btn-secondary">
|
||||
{{ _('Today') }}
|
||||
</button>
|
||||
<h2 id="calendarTitle" class="text-2xl font-semibold"></h2>
|
||||
</div>
|
||||
|
||||
<button id="nextBtn" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap gap-3 mt-4">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox" id="showEvents" checked class="form-checkbox">
|
||||
<span class="ml-2">{{ _('Events') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox" id="showTasks" checked class="form-checkbox">
|
||||
<span class="ml-2">{{ _('Tasks') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input type="checkbox" id="showTimeEntries" checked class="form-checkbox">
|
||||
<span class="ml-2">{{ _('Time Entries') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendar Grid -->
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6">
|
||||
<div id="calendarContainer" class="calendar-container">
|
||||
<!-- Calendar will be rendered here by JavaScript -->
|
||||
<div class="text-center py-12">
|
||||
<i class="fas fa-spinner fa-spin text-4xl text-primary mb-4"></i>
|
||||
<p class="text-muted">{{ _('Loading calendar...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event Details Modal -->
|
||||
<div id="eventModal" class="modal" style="display: none;">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{ _('Event Details') }}</h3>
|
||||
<button type="button" class="close" data-dismiss="modal">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" id="eventDetails">
|
||||
<!-- Event details will be loaded here -->
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">
|
||||
{{ _('Close') }}
|
||||
</button>
|
||||
<a id="editEventBtn" href="#" class="btn btn-primary">
|
||||
<i class="fas fa-edit mr-2"></i>{{ _('Edit') }}
|
||||
</a>
|
||||
<button id="deleteEventBtn" type="button" class="btn btn-danger">
|
||||
<i class="fas fa-trash mr-2"></i>{{ _('Delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pass data to JavaScript -->
|
||||
<script>
|
||||
window.calendarData = {
|
||||
viewType: '{{ view_type }}',
|
||||
currentDate: '{{ current_date.strftime('%Y-%m-%d') }}',
|
||||
apiUrl: '{{ url_for('calendar.get_events') }}',
|
||||
viewUrl: '{{ url_for('calendar.view_calendar') }}',
|
||||
newEventUrl: '{{ url_for('calendar.new_event') }}',
|
||||
editEventUrl: '{{ url_for('calendar.edit_event', event_id=0) }}'.replace('/0', ''),
|
||||
deleteEventUrl: '{{ url_for('calendar.delete_event', event_id=0) }}'.replace('/0', ''),
|
||||
moveEventUrl: '{{ url_for('calendar.move_event', event_id=0) }}'.replace('/0', ''),
|
||||
resizeEventUrl: '{{ url_for('calendar.resize_event', event_id=0) }}'.replace('/0', ''),
|
||||
csrfToken: '{{ csrf_token() }}'
|
||||
};
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script src="{{ url_for('static', filename='calendar.js') }}?v=9"></script>
|
||||
{% endblock %}
|
||||
|
||||
446
docs/CALENDAR_AGENDA_FEATURE.md
Normal file
446
docs/CALENDAR_AGENDA_FEATURE.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# Calendar/Agenda Support Documentation
|
||||
|
||||
## Overview
|
||||
|
||||
The Calendar/Agenda feature in TimeTracker provides a comprehensive view of all your events, tasks, and time entries in one place. This feature helps you plan your work, schedule meetings, and track deadlines more effectively.
|
||||
|
||||
## Features
|
||||
|
||||
### Calendar Views
|
||||
|
||||
The calendar supports three different view modes:
|
||||
|
||||
1. **Day View**: Shows hourly time slots for a single day with all events and time entries
|
||||
2. **Week View**: Displays a weekly grid with events across 7 days
|
||||
3. **Month View**: Traditional monthly calendar with events displayed on each day
|
||||
|
||||
### Event Management
|
||||
|
||||
#### Event Types
|
||||
|
||||
- **Event**: General calendar events
|
||||
- **Meeting**: Scheduled meetings with clients or team members
|
||||
- **Appointment**: One-on-one appointments
|
||||
- **Reminder**: Simple reminders for tasks or deadlines
|
||||
- **Deadline**: Important deadlines linked to tasks or projects
|
||||
|
||||
#### Event Properties
|
||||
|
||||
Each calendar event can have the following properties:
|
||||
|
||||
- **Title** (required): The name of the event
|
||||
- **Description**: Detailed description of the event
|
||||
- **Start Time** (required): When the event starts
|
||||
- **End Time** (required): When the event ends
|
||||
- **All-Day**: Mark event as all-day (no specific time)
|
||||
- **Location**: Physical or virtual location
|
||||
- **Color**: Custom color for visual organization
|
||||
- **Reminder**: Set reminder (5, 15, 30 minutes, 1 hour, or 1 day before)
|
||||
- **Private**: Mark event as private (visible only to you)
|
||||
|
||||
#### Associated Items
|
||||
|
||||
Events can be linked to:
|
||||
|
||||
- **Project**: Associate event with a specific project
|
||||
- **Task**: Link event to a task for better tracking
|
||||
- **Client**: Connect event to a client
|
||||
|
||||
### Recurring Events
|
||||
|
||||
Create events that repeat on a schedule:
|
||||
|
||||
- Set recurrence pattern using RRULE format
|
||||
- Example: `FREQ=WEEKLY;BYDAY=MO,WE,FR` for events every Monday, Wednesday, and Friday
|
||||
- Set an optional end date for the recurrence
|
||||
|
||||
### Integration with Tasks and Time Entries
|
||||
|
||||
The calendar automatically displays:
|
||||
|
||||
- **Tasks with due dates**: Shown as badges on their due date
|
||||
- **Time entries**: Your tracked time appears on the calendar
|
||||
- Toggle visibility of these items using the filter checkboxes
|
||||
|
||||
## User Guide
|
||||
|
||||
### Accessing the Calendar
|
||||
|
||||
1. Log in to TimeTracker
|
||||
2. Click on the **Calendar** link in the navigation menu
|
||||
3. The calendar will open with the current month view
|
||||
|
||||
### Creating a New Event
|
||||
|
||||
#### Method 1: Using the "New Event" Button
|
||||
|
||||
1. Click the **"New Event"** button at the top of the calendar
|
||||
2. Fill in the event details:
|
||||
- Enter a title
|
||||
- Set start and end dates/times
|
||||
- Add optional description, location, etc.
|
||||
- Link to project, task, or client if desired
|
||||
3. Click **"Create Event"** to save
|
||||
|
||||
#### Method 2: Quick Creation (Month View)
|
||||
|
||||
1. In month view, click on any date cell
|
||||
2. This opens the new event form with the date pre-filled
|
||||
3. Complete the event details and save
|
||||
|
||||
### Viewing Events
|
||||
|
||||
#### In Calendar View
|
||||
|
||||
- Events appear as colored badges on their scheduled dates
|
||||
- In month view, up to 3 events are shown per day
|
||||
- If more than 3 events exist, a "+X more" indicator appears
|
||||
- Click any event badge to view its details
|
||||
|
||||
#### Event Detail Page
|
||||
|
||||
1. Click on an event to view its full details
|
||||
2. The detail page shows:
|
||||
- Full event information
|
||||
- Associated project, task, or client (with links)
|
||||
- Duration calculation
|
||||
- Created and updated timestamps
|
||||
|
||||
### Editing Events
|
||||
|
||||
1. Click on an event to open its detail page
|
||||
2. Click the **"Edit"** button
|
||||
3. Make your changes
|
||||
4. Click **"Update Event"** to save
|
||||
|
||||
### Deleting Events
|
||||
|
||||
1. Open the event detail page
|
||||
2. Click the **"Delete"** button
|
||||
3. Confirm the deletion
|
||||
|
||||
### Drag and Drop (Coming Soon)
|
||||
|
||||
Future versions will support:
|
||||
- Dragging events to reschedule them
|
||||
- Resizing events to adjust duration
|
||||
|
||||
### Filtering the Calendar
|
||||
|
||||
Use the checkboxes at the top of the calendar to toggle visibility:
|
||||
|
||||
- **Events**: Show/hide calendar events
|
||||
- **Tasks**: Show/hide tasks with due dates
|
||||
- **Time Entries**: Show/hide tracked time
|
||||
|
||||
### Navigation
|
||||
|
||||
- **Today**: Jump to today's date
|
||||
- **Previous/Next**: Navigate to previous/next day, week, or month
|
||||
- **Date Selector**: Click on the date display to pick a specific date
|
||||
|
||||
## API Documentation
|
||||
|
||||
### API Endpoints
|
||||
|
||||
#### Get Events in Date Range
|
||||
|
||||
```http
|
||||
GET /api/calendar/events?start={start_date}&end={end_date}&include_tasks={boolean}&include_time_entries={boolean}
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `start`: ISO 8601 datetime (required)
|
||||
- `end`: ISO 8601 datetime (required)
|
||||
- `include_tasks`: Include tasks with due dates (default: true)
|
||||
- `include_time_entries`: Include time entries (default: true)
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"events": [...],
|
||||
"tasks": [...],
|
||||
"time_entries": [...]
|
||||
}
|
||||
```
|
||||
|
||||
#### Create Event
|
||||
|
||||
```http
|
||||
POST /api/calendar/events
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"title": "Team Meeting",
|
||||
"description": "Weekly sync",
|
||||
"start": "2025-01-15T10:00:00",
|
||||
"end": "2025-01-15T11:00:00",
|
||||
"allDay": false,
|
||||
"location": "Conference Room A",
|
||||
"eventType": "meeting",
|
||||
"projectId": 1,
|
||||
"taskId": null,
|
||||
"clientId": null,
|
||||
"color": "#3b82f6",
|
||||
"reminderMinutes": 30,
|
||||
"isPrivate": false,
|
||||
"isRecurring": false,
|
||||
"recurrenceRule": null,
|
||||
"recurrenceEndDate": null
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"event": { /* event object */ },
|
||||
"message": "Event created successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### Update Event
|
||||
|
||||
```http
|
||||
PUT /api/calendar/events/{event_id}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:** Same as create (partial updates supported)
|
||||
|
||||
#### Delete Event
|
||||
|
||||
```http
|
||||
DELETE /api/calendar/events/{event_id}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "Event deleted successfully"
|
||||
}
|
||||
```
|
||||
|
||||
#### Move Event (Drag & Drop)
|
||||
|
||||
```http
|
||||
POST /api/calendar/events/{event_id}/move
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"start": "2025-01-16T10:00:00",
|
||||
"end": "2025-01-16T11:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Resize Event
|
||||
|
||||
```http
|
||||
POST /api/calendar/events/{event_id}/resize
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{
|
||||
"end": "2025-01-15T12:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
## Database Schema
|
||||
|
||||
### CalendarEvent Model
|
||||
|
||||
```python
|
||||
class CalendarEvent(db.Model):
|
||||
id = Integer (Primary Key)
|
||||
user_id = Integer (Foreign Key to users.id)
|
||||
title = String(200) (Required)
|
||||
description = Text
|
||||
start_time = DateTime (Required, Indexed)
|
||||
end_time = DateTime (Required, Indexed)
|
||||
all_day = Boolean (Default: False)
|
||||
location = String(200)
|
||||
event_type = String(50) (Default: 'event', Indexed)
|
||||
|
||||
# Associations
|
||||
project_id = Integer (Foreign Key to projects.id)
|
||||
task_id = Integer (Foreign Key to tasks.id)
|
||||
client_id = Integer (Foreign Key to clients.id)
|
||||
|
||||
# Recurring events
|
||||
is_recurring = Boolean (Default: False)
|
||||
recurrence_rule = String(200) # RRULE format
|
||||
recurrence_end_date = DateTime
|
||||
parent_event_id = Integer (Foreign Key to calendar_events.id)
|
||||
|
||||
# Reminders and customization
|
||||
reminder_minutes = Integer
|
||||
color = String(7) # Hex color code
|
||||
is_private = Boolean (Default: False)
|
||||
|
||||
# Timestamps
|
||||
created_at = DateTime
|
||||
updated_at = DateTime
|
||||
```
|
||||
|
||||
### Relationships
|
||||
|
||||
- `user`: Many-to-one relationship with User
|
||||
- `project`: Many-to-one relationship with Project
|
||||
- `task`: Many-to-one relationship with Task
|
||||
- `client`: Many-to-one relationship with Client
|
||||
- `parent_event`: Self-referential for recurring event instances
|
||||
- `child_events`: One-to-many relationship for recurring event series
|
||||
|
||||
## Migration
|
||||
|
||||
The calendar feature is added via Alembic migration:
|
||||
|
||||
```bash
|
||||
# Migration file: migrations/versions/034_add_calendar_events_table.py
|
||||
flask db upgrade
|
||||
```
|
||||
|
||||
This creates the `calendar_events` table with all necessary indexes and foreign key constraints.
|
||||
|
||||
## Permissions
|
||||
|
||||
- **Users** can:
|
||||
- Create their own events
|
||||
- View their own events
|
||||
- Edit their own events
|
||||
- Delete their own events
|
||||
- View events linked to their assigned tasks
|
||||
|
||||
- **Admins** can:
|
||||
- View all events (except private events of other users)
|
||||
- Edit any event
|
||||
- Delete any event
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Event Organization
|
||||
|
||||
1. **Use Colors Wisely**: Assign colors to different event types for quick visual identification
|
||||
- Blue (#3b82f6) for regular meetings
|
||||
- Red (#ef4444) for deadlines
|
||||
- Green (#10b981) for client appointments
|
||||
- Purple (#8b5cf6) for personal events
|
||||
|
||||
2. **Link to Projects**: Always link events to projects when relevant for better reporting
|
||||
|
||||
3. **Set Reminders**: Use reminders for important meetings to avoid missing them
|
||||
|
||||
4. **Use Recurring Events**: Set up recurring events for weekly meetings instead of creating them manually
|
||||
|
||||
### Performance Tips
|
||||
|
||||
1. The calendar loads events for the visible date range only
|
||||
2. Large organizations should consider archiving old events (older than 6 months)
|
||||
3. Use the filters to focus on what's important
|
||||
|
||||
### Integration with Workflows
|
||||
|
||||
1. **Task Planning**: Create events for task work sessions
|
||||
2. **Client Meetings**: Link meetings to clients for better relationship tracking
|
||||
3. **Project Milestones**: Use deadline events for project milestones
|
||||
4. **Time Blocking**: Create events to block time for focused work
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Events Not Showing
|
||||
|
||||
1. Check date range - ensure events fall within the visible calendar range
|
||||
2. Verify filters - ensure event type is not filtered out
|
||||
3. Check permissions - private events are only visible to their creator
|
||||
|
||||
### Cannot Edit Event
|
||||
|
||||
- Verify you are the event owner or an admin
|
||||
- Check that the event still exists
|
||||
- Ensure you're logged in with the correct account
|
||||
|
||||
### Recurring Events Not Working
|
||||
|
||||
- Verify RRULE format is correct
|
||||
- Check that recurrence end date is after start date
|
||||
- Ensure parent event exists for child instances
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Frontend
|
||||
|
||||
- **JavaScript**: `app/static/calendar.js` - Calendar rendering and interaction
|
||||
- **CSS**: `app/static/calendar.css` - Calendar styling
|
||||
- **Templates**: `app/templates/calendar/` - HTML templates
|
||||
|
||||
### Backend
|
||||
|
||||
- **Models**: `app/models/calendar_event.py` - Data model
|
||||
- **Routes**: `app/routes/calendar.py` - API and view routes
|
||||
- **Tests**: `tests/test_calendar_event_model.py`, `tests/test_calendar_routes.py`
|
||||
|
||||
### Testing
|
||||
|
||||
Run calendar tests:
|
||||
|
||||
```bash
|
||||
# Model tests
|
||||
pytest tests/test_calendar_event_model.py -v
|
||||
|
||||
# Route tests
|
||||
pytest tests/test_calendar_routes.py -v
|
||||
|
||||
# All calendar tests
|
||||
pytest tests/test_calendar* -v
|
||||
|
||||
# Smoke tests
|
||||
pytest tests/test_calendar* -m smoke
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future improvements:
|
||||
|
||||
1. **iCal/ICS Import/Export**: Import events from other calendar applications
|
||||
2. **Sharing**: Share events with other users or teams
|
||||
3. **Email Notifications**: Send email reminders for events
|
||||
4. **Mobile App**: Dedicated mobile calendar view
|
||||
5. **Time Zone Support**: Better handling of events across time zones
|
||||
6. **Event Templates**: Create reusable event templates
|
||||
7. **Attendees**: Add multiple attendees to events
|
||||
8. **Conflict Detection**: Warn about overlapping events
|
||||
|
||||
## Support
|
||||
|
||||
For issues or feature requests related to the calendar:
|
||||
|
||||
1. Check this documentation first
|
||||
2. Review the test files for examples
|
||||
3. Check the GitHub issues for known problems
|
||||
4. Contact your system administrator
|
||||
|
||||
## Version History
|
||||
|
||||
- **Version 1.0** (2025-10-27): Initial calendar/agenda support
|
||||
- Day, week, and month views
|
||||
- Event CRUD operations
|
||||
- Integration with tasks and time entries
|
||||
- Recurring event support
|
||||
- API endpoints for all operations
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [TimeTracker User Guide](README.md)
|
||||
- [API Documentation](API_DOCUMENTATION.md)
|
||||
- [Task Management Guide](TASK_MANAGEMENT.md)
|
||||
- [Project Management Guide](PROJECT_MANAGEMENT.md)
|
||||
|
||||
67
migrations/versions/034_add_calendar_events_table.py
Normal file
67
migrations/versions/034_add_calendar_events_table.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Add calendar_events table for agenda/calendar support
|
||||
|
||||
Revision ID: 034_add_calendar_events
|
||||
Revises: 033_add_email_settings
|
||||
Create Date: 2025-10-27
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '034_add_calendar_events'
|
||||
down_revision = '033_add_email_settings'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create calendar_events table"""
|
||||
op.create_table(
|
||||
'calendar_events',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('title', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('start_time', sa.DateTime(), nullable=False),
|
||||
sa.Column('end_time', sa.DateTime(), nullable=False),
|
||||
sa.Column('all_day', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('location', sa.String(length=200), nullable=True),
|
||||
sa.Column('event_type', sa.String(length=50), nullable=False, server_default='event'),
|
||||
sa.Column('project_id', sa.Integer(), nullable=True),
|
||||
sa.Column('task_id', sa.Integer(), nullable=True),
|
||||
sa.Column('client_id', sa.Integer(), nullable=True),
|
||||
sa.Column('is_recurring', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('recurrence_rule', sa.String(length=200), nullable=True),
|
||||
sa.Column('recurrence_end_date', sa.DateTime(), nullable=True),
|
||||
sa.Column('parent_event_id', sa.Integer(), nullable=True),
|
||||
sa.Column('reminder_minutes', sa.Integer(), nullable=True),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('is_private', sa.Boolean(), nullable=False, server_default='0'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], name='fk_calendar_events_user_id'),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], name='fk_calendar_events_project_id'),
|
||||
sa.ForeignKeyConstraint(['task_id'], ['tasks.id'], name='fk_calendar_events_task_id'),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], name='fk_calendar_events_client_id'),
|
||||
sa.ForeignKeyConstraint(['parent_event_id'], ['calendar_events.id'], name='fk_calendar_events_parent_event_id'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for better query performance
|
||||
with op.batch_alter_table('calendar_events', schema=None) as batch_op:
|
||||
batch_op.create_index('ix_calendar_events_user_id', ['user_id'])
|
||||
batch_op.create_index('ix_calendar_events_start_time', ['start_time'])
|
||||
batch_op.create_index('ix_calendar_events_end_time', ['end_time'])
|
||||
batch_op.create_index('ix_calendar_events_event_type', ['event_type'])
|
||||
batch_op.create_index('ix_calendar_events_project_id', ['project_id'])
|
||||
batch_op.create_index('ix_calendar_events_task_id', ['task_id'])
|
||||
batch_op.create_index('ix_calendar_events_client_id', ['client_id'])
|
||||
batch_op.create_index('ix_calendar_events_parent_event_id', ['parent_event_id'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Drop calendar_events table"""
|
||||
op.drop_table('calendar_events')
|
||||
|
||||
667
tests/test_calendar_event_model.py
Normal file
667
tests/test_calendar_event_model.py
Normal file
@@ -0,0 +1,667 @@
|
||||
"""
|
||||
Test suite for CalendarEvent model.
|
||||
Tests model creation, relationships, properties, and business logic.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import CalendarEvent, User, Project, Task, Client, TimeEntry
|
||||
from app import db
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# CalendarEvent Model Tests
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
@pytest.mark.smoke
|
||||
def test_calendar_event_creation(app, user, project):
|
||||
"""Test basic calendar event creation."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=2)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Team Meeting",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
description="Weekly team sync",
|
||||
location="Conference Room A",
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
assert event.id is not None
|
||||
assert event.title == "Team Meeting"
|
||||
assert event.user_id == user.id
|
||||
assert event.start_time == start_time
|
||||
assert event.end_time == end_time
|
||||
assert event.description == "Weekly team sync"
|
||||
assert event.location == "Conference Room A"
|
||||
assert event.event_type == "meeting"
|
||||
assert event.all_day is False
|
||||
assert event.is_private is False
|
||||
assert event.is_recurring is False
|
||||
assert event.created_at is not None
|
||||
assert event.updated_at is not None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_all_day(app, user):
|
||||
"""Test all-day calendar event."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
end_time = start_time.replace(hour=23, minute=59, second=59)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Holiday",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
all_day=True,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
assert event.all_day is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_with_project(app, user, project):
|
||||
"""Test calendar event associated with a project."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Project Review",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
project_id=project.id,
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
db.session.refresh(event)
|
||||
assert event.project is not None
|
||||
assert event.project.id == project.id
|
||||
assert event.project.name == project.name
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_with_task(app, user, project):
|
||||
"""Test calendar event associated with a task."""
|
||||
with app.app_context():
|
||||
# Create a task
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
name="Complete documentation",
|
||||
created_by=user.id,
|
||||
assigned_to=user.id
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=3)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Work on documentation",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
task_id=task.id,
|
||||
event_type="deadline"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
db.session.refresh(event)
|
||||
assert event.task is not None
|
||||
assert event.task.id == task.id
|
||||
assert event.task.name == "Complete documentation"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_with_client(app, user, test_client):
|
||||
"""Test calendar event associated with a client."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Client Meeting",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
client_id=test_client.id,
|
||||
event_type="appointment"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
db.session.refresh(event)
|
||||
assert event.client is not None
|
||||
assert event.client.id == test_client.id
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_recurring(app, user):
|
||||
"""Test recurring calendar event."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
recurrence_end = start_time + timedelta(days=90)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Weekly Standup",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_recurring=True,
|
||||
recurrence_rule="FREQ=WEEKLY;BYDAY=MO,WE,FR",
|
||||
recurrence_end_date=recurrence_end,
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
assert event.is_recurring is True
|
||||
assert event.recurrence_rule == "FREQ=WEEKLY;BYDAY=MO,WE,FR"
|
||||
assert event.recurrence_end_date == recurrence_end
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_with_reminder(app, user):
|
||||
"""Test calendar event with reminder."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Important Meeting",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
reminder_minutes=30,
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
assert event.reminder_minutes == 30
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_with_color(app, user):
|
||||
"""Test calendar event with custom color."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Colored Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
color="#FF5733",
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
assert event.color == "#FF5733"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_private(app, user):
|
||||
"""Test private calendar event."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Private Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_private=True,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
assert event.is_private is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_duration_hours(app, user):
|
||||
"""Test calendar event duration calculation."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=2, minutes=30)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
assert event.duration_hours() == 2.5
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_to_dict(app, user, project):
|
||||
"""Test calendar event serialization to dictionary."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
description="Test description",
|
||||
location="Office",
|
||||
event_type="meeting",
|
||||
project_id=project.id,
|
||||
all_day=False,
|
||||
is_private=False,
|
||||
color="#3b82f6",
|
||||
reminder_minutes=15
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
event_dict = event.to_dict()
|
||||
|
||||
assert 'id' in event_dict
|
||||
assert 'title' in event_dict
|
||||
assert 'description' in event_dict
|
||||
assert 'start' in event_dict
|
||||
assert 'end' in event_dict
|
||||
assert 'allDay' in event_dict
|
||||
assert 'location' in event_dict
|
||||
assert 'eventType' in event_dict
|
||||
assert 'projectId' in event_dict
|
||||
assert 'color' in event_dict
|
||||
assert 'isPrivate' in event_dict
|
||||
assert 'reminderMinutes' in event_dict
|
||||
|
||||
assert event_dict['title'] == "Test Event"
|
||||
assert event_dict['description'] == "Test description"
|
||||
assert event_dict['location'] == "Office"
|
||||
assert event_dict['eventType'] == "meeting"
|
||||
assert event_dict['projectId'] == project.id
|
||||
assert event_dict['color'] == "#3b82f6"
|
||||
assert event_dict['reminderMinutes'] == 15
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_user_relationship(app, user):
|
||||
"""Test calendar event user relationship."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
db.session.refresh(event)
|
||||
assert event.user is not None
|
||||
assert event.user.id == user.id
|
||||
assert event.user.username == user.username
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_parent_child_relationship(app, user):
|
||||
"""Test recurring calendar event parent-child relationship."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
# Create parent event
|
||||
parent_event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Recurring Meeting",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_recurring=True,
|
||||
recurrence_rule="FREQ=WEEKLY",
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(parent_event)
|
||||
db.session.commit()
|
||||
|
||||
# Create child event (instance of recurring event)
|
||||
child_start = start_time + timedelta(days=7)
|
||||
child_end = child_start + timedelta(hours=1)
|
||||
child_event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Recurring Meeting",
|
||||
start_time=child_start,
|
||||
end_time=child_end,
|
||||
parent_event_id=parent_event.id,
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(child_event)
|
||||
db.session.commit()
|
||||
|
||||
db.session.refresh(parent_event)
|
||||
db.session.refresh(child_event)
|
||||
|
||||
assert child_event.parent_event is not None
|
||||
assert child_event.parent_event.id == parent_event.id
|
||||
assert parent_event.child_events.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_get_events_in_range(app, user):
|
||||
"""Test getting events in a date range."""
|
||||
with app.app_context():
|
||||
# Create events
|
||||
now = datetime.now()
|
||||
|
||||
# Event within range
|
||||
event1 = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event 1",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
event_type="event"
|
||||
)
|
||||
|
||||
# Event outside range
|
||||
event2 = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event 2",
|
||||
start_time=now + timedelta(days=30),
|
||||
end_time=now + timedelta(days=30, hours=1),
|
||||
event_type="event"
|
||||
)
|
||||
|
||||
db.session.add_all([event1, event2])
|
||||
db.session.commit()
|
||||
|
||||
# Get events in range
|
||||
start_date = now - timedelta(days=1)
|
||||
end_date = now + timedelta(days=7)
|
||||
result = CalendarEvent.get_events_in_range(
|
||||
user_id=user.id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
include_tasks=False,
|
||||
include_time_entries=False
|
||||
)
|
||||
|
||||
assert len(result['events']) == 1
|
||||
assert result['events'][0]['title'] == "Event 1"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_get_events_in_range_with_tasks(app, user, project):
|
||||
"""Test getting events with tasks in date range."""
|
||||
with app.app_context():
|
||||
# Create event
|
||||
now = datetime.now()
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
|
||||
# Create task with due date
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
name="Task with due date",
|
||||
created_by=user.id,
|
||||
assigned_to=user.id,
|
||||
due_date=now.date() + timedelta(days=3),
|
||||
status='todo'
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
# Get events including tasks
|
||||
start_date = now - timedelta(days=1)
|
||||
end_date = now + timedelta(days=7)
|
||||
result = CalendarEvent.get_events_in_range(
|
||||
user_id=user.id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
include_tasks=True,
|
||||
include_time_entries=False
|
||||
)
|
||||
|
||||
assert len(result['events']) == 1
|
||||
assert len(result['tasks']) == 1
|
||||
assert result['tasks'][0]['title'] == "Task with due date"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_get_events_in_range_with_time_entries(app, user, project):
|
||||
"""Test getting events with time entries in date range."""
|
||||
with app.app_context():
|
||||
# Create event
|
||||
now = datetime.now()
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
|
||||
# Create time entry
|
||||
time_entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=now + timedelta(hours=2),
|
||||
end_time=now + timedelta(hours=4),
|
||||
notes="Working on feature"
|
||||
)
|
||||
db.session.add(time_entry)
|
||||
db.session.commit()
|
||||
|
||||
# Get events including time entries
|
||||
start_date = now - timedelta(days=1)
|
||||
end_date = now + timedelta(days=1)
|
||||
result = CalendarEvent.get_events_in_range(
|
||||
user_id=user.id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
include_tasks=False,
|
||||
include_time_entries=True
|
||||
)
|
||||
|
||||
assert len(result['events']) == 1
|
||||
assert len(result['time_entries']) == 1
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_repr(app, user):
|
||||
"""Test calendar event string representation."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
repr_str = repr(event)
|
||||
assert 'CalendarEvent' in repr_str
|
||||
assert 'Test Event' in repr_str
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_cascade_delete_with_user(app, user):
|
||||
"""Test that events are deleted when user is deleted."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
event_id = event.id
|
||||
|
||||
# Delete user
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
# Event should be deleted
|
||||
deleted_event = CalendarEvent.query.get(event_id)
|
||||
assert deleted_event is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_cascade_delete_with_parent(app, user):
|
||||
"""Test that child events are deleted when parent is deleted."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
# Create parent event
|
||||
parent_event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Parent Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
is_recurring=True,
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(parent_event)
|
||||
db.session.commit()
|
||||
|
||||
# Create child event
|
||||
child_start = start_time + timedelta(days=7)
|
||||
child_end = child_start + timedelta(hours=1)
|
||||
child_event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Child Event",
|
||||
start_time=child_start,
|
||||
end_time=child_end,
|
||||
parent_event_id=parent_event.id,
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(child_event)
|
||||
db.session.commit()
|
||||
|
||||
child_id = child_event.id
|
||||
|
||||
# Delete parent
|
||||
db.session.delete(parent_event)
|
||||
db.session.commit()
|
||||
|
||||
# Child should be deleted
|
||||
deleted_child = CalendarEvent.query.get(child_id)
|
||||
assert deleted_child is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_different_types(app, user):
|
||||
"""Test calendar events with different types."""
|
||||
with app.app_context():
|
||||
now = datetime.now()
|
||||
event_types = ['event', 'meeting', 'appointment', 'reminder', 'deadline']
|
||||
|
||||
events = []
|
||||
for event_type in event_types:
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title=f"Test {event_type}",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
event_type=event_type
|
||||
)
|
||||
events.append(event)
|
||||
|
||||
db.session.add_all(events)
|
||||
db.session.commit()
|
||||
|
||||
for idx, event_type in enumerate(event_types):
|
||||
assert events[idx].event_type == event_type
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.models
|
||||
def test_calendar_event_user_has_events_relationship(app, user):
|
||||
"""Test that user has calendar_events relationship."""
|
||||
with app.app_context():
|
||||
now = datetime.now()
|
||||
|
||||
event1 = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event 1",
|
||||
start_time=now,
|
||||
end_time=now + timedelta(hours=1),
|
||||
event_type="event"
|
||||
)
|
||||
event2 = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event 2",
|
||||
start_time=now + timedelta(days=1),
|
||||
end_time=now + timedelta(days=1, hours=1),
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add_all([event1, event2])
|
||||
db.session.commit()
|
||||
|
||||
db.session.refresh(user)
|
||||
assert user.calendar_events.count() == 2
|
||||
|
||||
585
tests/test_calendar_routes.py
Normal file
585
tests/test_calendar_routes.py
Normal file
@@ -0,0 +1,585 @@
|
||||
"""
|
||||
Test suite for calendar routes and endpoints.
|
||||
Tests calendar views, event CRUD operations, and API endpoints.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from app.models import CalendarEvent, Task
|
||||
from app import db
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calendar View Routes
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_calendar_view_accessible(authenticated_client):
|
||||
"""Test that calendar view is accessible for authenticated users."""
|
||||
response = authenticated_client.get('/calendar')
|
||||
assert response.status_code == 200
|
||||
assert b'Calendar' in response.data or b'calendar' in response.data
|
||||
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_calendar_view_requires_authentication(client):
|
||||
"""Test that calendar view requires authentication."""
|
||||
response = client.get('/calendar', follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
assert '/login' in response.location or 'login' in response.location.lower()
|
||||
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_calendar_day_view(authenticated_client):
|
||||
"""Test calendar day view."""
|
||||
response = authenticated_client.get('/calendar?view=day')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_calendar_week_view(authenticated_client):
|
||||
"""Test calendar week view."""
|
||||
response = authenticated_client.get('/calendar?view=week')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_calendar_month_view(authenticated_client):
|
||||
"""Test calendar month view."""
|
||||
response = authenticated_client.get('/calendar?view=month')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_calendar_with_date_parameter(authenticated_client):
|
||||
"""Test calendar view with specific date."""
|
||||
test_date = '2025-01-15'
|
||||
response = authenticated_client.get(f'/calendar?date={test_date}')
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calendar Event API Endpoints
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_get_calendar_events_api(authenticated_client, user, app):
|
||||
"""Test getting calendar events via API."""
|
||||
with app.app_context():
|
||||
# Create test event
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=2)
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
||||
# Query events
|
||||
start_str = (start_time - timedelta(days=1)).isoformat()
|
||||
end_str = (end_time + timedelta(days=1)).isoformat()
|
||||
response = authenticated_client.get(
|
||||
f'/api/calendar/events?start={start_str}&end={end_str}'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'events' in data
|
||||
assert len(data['events']) > 0
|
||||
assert data['events'][0]['title'] == "Test Event"
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_get_calendar_events_missing_dates(authenticated_client):
|
||||
"""Test getting events without required date parameters."""
|
||||
response = authenticated_client.get('/api/calendar/events')
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_create_calendar_event_api(authenticated_client, app):
|
||||
"""Test creating a calendar event via API."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event_data = {
|
||||
'title': 'New Meeting',
|
||||
'description': 'Team sync',
|
||||
'start': start_time.isoformat(),
|
||||
'end': end_time.isoformat(),
|
||||
'allDay': False,
|
||||
'location': 'Office',
|
||||
'eventType': 'meeting'
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
'/api/calendar/events',
|
||||
data=json.dumps(event_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
assert 'event' in data
|
||||
assert data['event']['title'] == 'New Meeting'
|
||||
|
||||
# Verify event was created in database
|
||||
event = CalendarEvent.query.filter_by(title='New Meeting').first()
|
||||
assert event is not None
|
||||
assert event.description == 'Team sync'
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_create_calendar_event_missing_required_fields(authenticated_client):
|
||||
"""Test creating event without required fields."""
|
||||
event_data = {
|
||||
'description': 'Missing title'
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
'/api/calendar/events',
|
||||
data=json.dumps(event_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
data = response.get_json()
|
||||
assert 'error' in data
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_get_single_event_api(authenticated_client, user, app):
|
||||
"""Test getting a single calendar event."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
response = authenticated_client.get(f'/api/calendar/events/{event_id}')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['title'] == "Test Event"
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_get_nonexistent_event(authenticated_client):
|
||||
"""Test getting a non-existent event."""
|
||||
response = authenticated_client.get('/api/calendar/events/99999')
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_update_calendar_event_api(authenticated_client, user, app):
|
||||
"""Test updating a calendar event via API."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Original Title",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
update_data = {
|
||||
'title': 'Updated Title',
|
||||
'description': 'Updated description'
|
||||
}
|
||||
|
||||
response = authenticated_client.put(
|
||||
f'/api/calendar/events/{event_id}',
|
||||
data=json.dumps(update_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
assert data['event']['title'] == 'Updated Title'
|
||||
|
||||
# Verify in database
|
||||
db.session.refresh(event)
|
||||
assert event.title == 'Updated Title'
|
||||
assert event.description == 'Updated description'
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_update_event_permission_denied(authenticated_client, admin_user, app):
|
||||
"""Test that users cannot update other users' events."""
|
||||
with app.app_context():
|
||||
# Create event for admin user
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=admin_user.id,
|
||||
title="Admin Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
# Try to update as regular user
|
||||
update_data = {'title': 'Hacked Title'}
|
||||
response = authenticated_client.put(
|
||||
f'/api/calendar/events/{event_id}',
|
||||
data=json.dumps(update_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_delete_calendar_event_api(authenticated_client, user, app):
|
||||
"""Test deleting a calendar event via API."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event to Delete",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
response = authenticated_client.delete(f'/api/calendar/events/{event_id}')
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
|
||||
# Verify deletion in database
|
||||
deleted_event = CalendarEvent.query.get(event_id)
|
||||
assert deleted_event is None
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_delete_event_permission_denied(authenticated_client, admin_user, app):
|
||||
"""Test that users cannot delete other users' events."""
|
||||
with app.app_context():
|
||||
# Create event for admin user
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=admin_user.id,
|
||||
title="Admin Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
# Try to delete as regular user
|
||||
response = authenticated_client.delete(f'/api/calendar/events/{event_id}')
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_move_calendar_event_api(authenticated_client, user, app):
|
||||
"""Test moving a calendar event (drag and drop)."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event to Move",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
new_start = start_time + timedelta(days=1)
|
||||
new_end = end_time + timedelta(days=1)
|
||||
|
||||
move_data = {
|
||||
'start': new_start.isoformat(),
|
||||
'end': new_end.isoformat()
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
f'/api/calendar/events/{event_id}/move',
|
||||
data=json.dumps(move_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
|
||||
# Verify in database
|
||||
db.session.refresh(event)
|
||||
assert event.start_time.date() == new_start.date()
|
||||
|
||||
|
||||
@pytest.mark.api
|
||||
@pytest.mark.routes
|
||||
def test_resize_calendar_event_api(authenticated_client, user, app):
|
||||
"""Test resizing a calendar event."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Event to Resize",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
new_end = end_time + timedelta(hours=1)
|
||||
|
||||
resize_data = {
|
||||
'end': new_end.isoformat()
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
f'/api/calendar/events/{event_id}/resize',
|
||||
data=json.dumps(resize_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert data['success'] is True
|
||||
|
||||
# Verify duration changed
|
||||
db.session.refresh(event)
|
||||
assert event.duration_hours() == 2.0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calendar Event Form Routes
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_new_event_form_accessible(authenticated_client):
|
||||
"""Test that new event form is accessible."""
|
||||
response = authenticated_client.get('/calendar/event/new')
|
||||
assert response.status_code == 200
|
||||
assert b'New Event' in response.data or b'new event' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_edit_event_form_accessible(authenticated_client, user, app):
|
||||
"""Test that edit event form is accessible."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
response = authenticated_client.get(f'/calendar/event/{event_id}/edit')
|
||||
assert response.status_code == 200
|
||||
assert b'Edit' in response.data or b'edit' in response.data.lower()
|
||||
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_edit_event_form_permission_denied(authenticated_client, admin_user, app):
|
||||
"""Test that users cannot access edit form for other users' events."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=admin_user.id,
|
||||
title="Admin Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
event_type="event"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
response = authenticated_client.get(
|
||||
f'/calendar/event/{event_id}/edit',
|
||||
follow_redirects=False
|
||||
)
|
||||
assert response.status_code == 302 # Redirected
|
||||
|
||||
|
||||
@pytest.mark.routes
|
||||
def test_view_event_detail(authenticated_client, user, app):
|
||||
"""Test viewing event detail page."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
event = CalendarEvent(
|
||||
user_id=user.id,
|
||||
title="Test Event",
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
description="Test description",
|
||||
location="Test location",
|
||||
event_type="meeting"
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
event_id = event.id
|
||||
|
||||
response = authenticated_client.get(f'/calendar/event/{event_id}')
|
||||
assert response.status_code == 200
|
||||
assert b'Test Event' in response.data
|
||||
assert b'Test description' in response.data
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Calendar Integration Tests
|
||||
# ============================================================================
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.routes
|
||||
def test_calendar_shows_tasks(authenticated_client, user, project, app):
|
||||
"""Test that calendar includes tasks with due dates."""
|
||||
with app.app_context():
|
||||
# Create task with due date
|
||||
task = Task(
|
||||
project_id=project.id,
|
||||
name="Task with due date",
|
||||
created_by=user.id,
|
||||
assigned_to=user.id,
|
||||
due_date=datetime.now().date() + timedelta(days=3),
|
||||
status='todo'
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.commit()
|
||||
|
||||
# Query calendar events API
|
||||
start_str = datetime.now().isoformat()
|
||||
end_str = (datetime.now() + timedelta(days=7)).isoformat()
|
||||
response = authenticated_client.get(
|
||||
f'/api/calendar/events?start={start_str}&end={end_str}&include_tasks=true'
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.get_json()
|
||||
assert 'tasks' in data
|
||||
assert len(data['tasks']) > 0
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@pytest.mark.routes
|
||||
def test_calendar_with_project_filter(authenticated_client, user, project, app):
|
||||
"""Test creating event with project association."""
|
||||
with app.app_context():
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=1)
|
||||
|
||||
event_data = {
|
||||
'title': 'Project Meeting',
|
||||
'start': start_time.isoformat(),
|
||||
'end': end_time.isoformat(),
|
||||
'projectId': project.id,
|
||||
'eventType': 'meeting'
|
||||
}
|
||||
|
||||
response = authenticated_client.post(
|
||||
'/api/calendar/events',
|
||||
data=json.dumps(event_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
data = response.get_json()
|
||||
assert data['event']['projectId'] == project.id
|
||||
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.routes
|
||||
def test_calendar_event_creation_workflow(authenticated_client, user, app):
|
||||
"""Test complete workflow of creating and viewing an event."""
|
||||
with app.app_context():
|
||||
# Create event
|
||||
start_time = datetime.now()
|
||||
end_time = start_time + timedelta(hours=2)
|
||||
|
||||
event_data = {
|
||||
'title': 'Complete Workflow Test',
|
||||
'description': 'Testing full workflow',
|
||||
'start': start_time.isoformat(),
|
||||
'end': end_time.isoformat(),
|
||||
'location': 'Test Location',
|
||||
'eventType': 'meeting',
|
||||
'color': '#3b82f6',
|
||||
'reminderMinutes': 30
|
||||
}
|
||||
|
||||
# Create via API
|
||||
response = authenticated_client.post(
|
||||
'/api/calendar/events',
|
||||
data=json.dumps(event_data),
|
||||
content_type='application/json'
|
||||
)
|
||||
assert response.status_code == 201
|
||||
event_id = response.get_json()['event']['id']
|
||||
|
||||
# Retrieve via API
|
||||
response = authenticated_client.get(f'/api/calendar/events/{event_id}')
|
||||
assert response.status_code == 200
|
||||
event = response.get_json()
|
||||
assert event['title'] == 'Complete Workflow Test'
|
||||
assert event['reminderMinutes'] == 30
|
||||
|
||||
# View detail page
|
||||
response = authenticated_client.get(f'/calendar/event/{event_id}')
|
||||
assert response.status_code == 200
|
||||
assert b'Complete Workflow Test' in response.data
|
||||
|
||||
Reference in New Issue
Block a user