mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-04-30 01:09:42 -05:00
Merge pull request #32 from DRYTRIX/feat-CommonNameForUser
feat: add real name support and fix task detail error
This commit is contained in:
@@ -125,7 +125,9 @@ class Project(db.Model):
|
||||
from .user import User
|
||||
|
||||
query = db.session.query(
|
||||
User.id,
|
||||
User.username,
|
||||
User.full_name,
|
||||
db.func.sum(TimeEntry.duration_seconds).label('total_seconds')
|
||||
).join(TimeEntry).filter(
|
||||
TimeEntry.project_id == self.id,
|
||||
@@ -138,14 +140,14 @@ class Project(db.Model):
|
||||
if end_date:
|
||||
query = query.filter(TimeEntry.start_time <= end_date)
|
||||
|
||||
results = query.group_by(User.username).all()
|
||||
results = query.group_by(User.id, User.username, User.full_name).all()
|
||||
|
||||
return [
|
||||
{
|
||||
'username': username,
|
||||
'username': (full_name.strip() if full_name and full_name.strip() else username),
|
||||
'total_hours': round(total_seconds / 3600, 2)
|
||||
}
|
||||
for username, total_seconds in results
|
||||
for _id, username, full_name, total_seconds in results
|
||||
]
|
||||
|
||||
def archive(self):
|
||||
|
||||
@@ -10,6 +10,7 @@ class User(UserMixin, db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False, index=True)
|
||||
full_name = db.Column(db.String(200), nullable=True)
|
||||
role = db.Column(db.String(20), default='user', nullable=False) # 'user' or 'admin'
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
last_login = db.Column(db.DateTime, nullable=True)
|
||||
@@ -50,6 +51,13 @@ class User(UserMixin, db.Model):
|
||||
TimeEntry.end_time.isnot(None)
|
||||
).scalar() or 0
|
||||
return round(total_seconds / 3600, 2)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Preferred display name: full name if available, else username"""
|
||||
if self.full_name and self.full_name.strip():
|
||||
return self.full_name.strip()
|
||||
return self.username
|
||||
|
||||
def get_recent_entries(self, limit=10):
|
||||
"""Get recent time entries for this user"""
|
||||
@@ -70,6 +78,8 @@ class User(UserMixin, db.Model):
|
||||
return {
|
||||
'id': self.id,
|
||||
'username': self.username,
|
||||
'full_name': self.full_name,
|
||||
'display_name': self.display_name,
|
||||
'role': self.role,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'last_login': self.last_login.isoformat() if self.last_login else None,
|
||||
|
||||
+9
-3
@@ -93,9 +93,15 @@ def profile():
|
||||
def edit_profile():
|
||||
"""Edit user profile"""
|
||||
if request.method == 'POST':
|
||||
# For now, just update last login timestamp
|
||||
current_user.update_last_login()
|
||||
flash('Profile updated successfully', 'success')
|
||||
# Update real name if provided
|
||||
full_name = request.form.get('full_name', '').strip()
|
||||
current_user.full_name = full_name or None
|
||||
try:
|
||||
db.session.commit()
|
||||
flash('Profile updated successfully', 'success')
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
flash('Could not update your profile due to a database error.', 'error')
|
||||
return redirect(url_for('auth.profile'))
|
||||
|
||||
return render_template('auth/edit_profile.html')
|
||||
|
||||
@@ -111,7 +111,7 @@ def project_report():
|
||||
if project.hourly_rate:
|
||||
agg['billable_amount'] += hours * float(project.hourly_rate)
|
||||
# per-user totals
|
||||
username = entry.user.username if entry.user else 'Unknown'
|
||||
username = entry.user.display_name if entry.user else 'Unknown'
|
||||
agg['user_totals'][username] = agg['user_totals'].get(username, 0.0) + hours
|
||||
|
||||
# Finalize structures
|
||||
@@ -205,7 +205,7 @@ def user_report():
|
||||
projects_set.add(entry.project.id)
|
||||
if entry.user:
|
||||
users_set.add(entry.user.id)
|
||||
username = entry.user.username if entry.user else 'Unknown'
|
||||
username = entry.user.display_name if entry.user else 'Unknown'
|
||||
if username not in user_totals:
|
||||
user_totals[username] = {
|
||||
'hours': 0,
|
||||
@@ -291,7 +291,7 @@ def export_csv():
|
||||
for entry in entries:
|
||||
writer.writerow([
|
||||
entry.id,
|
||||
entry.user.username,
|
||||
entry.user.display_name,
|
||||
entry.project.name,
|
||||
entry.project.client,
|
||||
entry.start_time.isoformat(),
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
<div class="form-text">Usernames cannot be changed.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Full name</label>
|
||||
<input type="text" name="full_name" class="form-control" value="{{ current_user.full_name or '' }}" placeholder="Enter your real name">
|
||||
<div class="form-text">Shown in tasks and reports when provided.</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Role</label>
|
||||
<input type="text" class="form-control" value="{{ current_user.role|capitalize }}" disabled>
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<div class="col-sm-4 text-muted">Username</div>
|
||||
<div class="col-sm-8"><strong>{{ current_user.username }}</strong></div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted">Full name</div>
|
||||
<div class="col-sm-8">{{ current_user.full_name or '—' }}</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 text-muted">Role</div>
|
||||
<div class="col-sm-8">
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 36px; height: 36px;">
|
||||
<i class="fas fa-user text-primary"></i>
|
||||
</div>
|
||||
<span>{{ current_user.username }}</span>
|
||||
<span>{{ current_user.display_name }}</span>
|
||||
</a>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<!-- Toast Container -->
|
||||
<div id="toast-container" class="toast-container position-fixed top-0 end-0 p-3"></div>
|
||||
|
||||
<!-- Template meta for JS flags -->
|
||||
<div id="dashboard-meta" data-has-active-timer="{{ 1 if active_timer else 0 }}" style="display:none;"></div>
|
||||
|
||||
<!-- Enhanced Welcome Section -->
|
||||
<div class="row section-spacing">
|
||||
<div class="col-12">
|
||||
@@ -19,7 +22,7 @@
|
||||
{% else %}
|
||||
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="DryTrix Logo" class="me-3" width="32" height="32">
|
||||
{% endif %}
|
||||
<h2 class="mb-0">Welcome back, {{ current_user.username }}!</h2>
|
||||
<h2 class="mb-0">Welcome back, {{ current_user.display_name }}!</h2>
|
||||
</div>
|
||||
<p class="text-muted mb-0 fs-5">Track your productivity and manage your time effectively with <strong>DryTrix</strong> TimeTracker</p>
|
||||
</div>
|
||||
@@ -398,48 +401,48 @@
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
let timerInterval;
|
||||
|
||||
{% if active_timer %}
|
||||
// Enhanced timer update
|
||||
function updateTimer() {
|
||||
fetch('/api/timer/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.active && data.timer) {
|
||||
const display = document.getElementById('timer-display');
|
||||
if (display) {
|
||||
// Prefer server-provided current duration; fallback to computing from start_time
|
||||
let totalSeconds = typeof data.timer.current_duration === 'number'
|
||||
? data.timer.current_duration
|
||||
: Math.floor((new Date() - new Date(data.timer.start_time)) / 1000);
|
||||
if (totalSeconds < 0 || Number.isNaN(totalSeconds)) totalSeconds = 0;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
display.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
const HAS_ACTIVE_TIMER = !!Number(document.getElementById('dashboard-meta')?.getAttribute('data-has-active-timer') || '0');
|
||||
if (HAS_ACTIVE_TIMER) {
|
||||
// Enhanced timer update
|
||||
function updateTimer() {
|
||||
fetch('/api/timer/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.active && data.timer) {
|
||||
const display = document.getElementById('timer-display');
|
||||
if (display) {
|
||||
// Prefer server-provided current duration; fallback to computing from start_time
|
||||
let totalSeconds = typeof data.timer.current_duration === 'number'
|
||||
? data.timer.current_duration
|
||||
: Math.floor((new Date() - new Date(data.timer.start_time)) / 1000);
|
||||
if (totalSeconds < 0 || Number.isNaN(totalSeconds)) totalSeconds = 0;
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
display.textContent = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
} else {
|
||||
// Timer stopped, reload page
|
||||
clearInterval(timerInterval);
|
||||
location.reload();
|
||||
}
|
||||
} else {
|
||||
// Timer stopped, reload page
|
||||
clearInterval(timerInterval);
|
||||
location.reload();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating timer:', error);
|
||||
});
|
||||
}
|
||||
|
||||
// Update timer immediately and then every second
|
||||
updateTimer();
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error updating timer:', error);
|
||||
});
|
||||
}
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Update timer immediately and then every second
|
||||
updateTimer();
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Populate tasks when project changes
|
||||
const projectSelect = document.getElementById('project_id');
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
<option value="">Unassigned</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if request.form.get('assigned_to')|int == user.id %}selected{% endif %}>
|
||||
{{ user.username }}
|
||||
{{ user.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
<option value="">Unassigned</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if task.assigned_to == user.id %}selected{% endif %}>
|
||||
{{ user.username }}
|
||||
{{ user.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -188,7 +188,7 @@
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height:24px;">
|
||||
<i class="fas fa-user text-info fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.assigned_user.username }}</span>
|
||||
<span>{{ task.assigned_user.display_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -153,7 +153,7 @@
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if assigned_to == user.id %}selected{% endif %}>
|
||||
{{ user.username }}
|
||||
{{ user.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -240,7 +240,7 @@
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 20px; height: 20px;">
|
||||
<i class="fas fa-user text-info fa-xs"></i>
|
||||
</div>
|
||||
<span class="text-muted small">{{ task.assigned_user.username }}</span>
|
||||
<span class="text-muted small">{{ task.assigned_user.display_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -247,7 +247,7 @@
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 20px; height: 20px;">
|
||||
<i class="fas fa-user text-info fa-xs"></i>
|
||||
</div>
|
||||
<span class="text-muted small">{{ task.assigned_user.username }}</span>
|
||||
<span class="text-muted small">{{ task.assigned_user.display_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<br><small class="text-muted">
|
||||
<i class="fas fa-user"></i>
|
||||
{% if task.assigned_user %}
|
||||
{{ task.assigned_user.username }}
|
||||
{{ task.assigned_user.display_name }}
|
||||
{% else %}
|
||||
Unassigned
|
||||
{% endif %}
|
||||
|
||||
@@ -178,7 +178,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in task.time_entries.order_by(desc('start_time')).limit(5).all() %}
|
||||
{% for entry in time_entries[:5] %}
|
||||
<tr>
|
||||
<td>{{ entry.start_time.strftime('%b %d, %Y') }}</td>
|
||||
<td>
|
||||
@@ -189,7 +189,7 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.notes[:50] if entry.notes else '-' }}</td>
|
||||
<td>{{ entry.user.username if entry.user else '-' }}</td>
|
||||
<td>{{ entry.user.display_name if entry.user else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -236,7 +236,7 @@
|
||||
<div class="bg-info bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-user text-info fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.assigned_user.username }}</span>
|
||||
<span>{{ task.assigned_user.display_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -247,7 +247,7 @@
|
||||
<div class="bg-secondary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-user-plus text-secondary fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.creator.username }}</span>
|
||||
<span>{{ task.creator.display_name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Add full_name to users
|
||||
|
||||
Revision ID: 002
|
||||
Revises: 001
|
||||
Create Date: 2025-01-15 11:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '002'
|
||||
down_revision = '001'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column('users', sa.Column('full_name', sa.String(length=200), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column('users', 'full_name')
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.user.username }}</td>
|
||||
<td>{{ entry.user.display_name }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
|
||||
{{ entry.project.name }}
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<table class="table table-hover mb-0" id="usersTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Username</th>
|
||||
<th>User</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
@@ -110,7 +110,7 @@
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<strong>{{ user.username }}</strong>
|
||||
<strong>{{ user.display_name }}</strong>
|
||||
{% if user.active_timer %}
|
||||
<br><small class="text-warning">
|
||||
<i class="fas fa-clock"></i> Timer Running
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="user-info">
|
||||
<div class="user-name">{{ entry.user.username }}</div>
|
||||
<div class="user-name">{{ entry.user.display_name }}</div>
|
||||
<small class="text-muted">{{ entry.start_time.strftime('%I:%M %p') }}</small>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
<div class="info-section">
|
||||
<div class="info-item">
|
||||
<div class="info-label">Created by</div>
|
||||
<div class="info-value">{{ invoice.creator.username }}</div>
|
||||
<div class="info-value">{{ invoice.creator.display_name }}</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<div class="info-label">Created</div>
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
<div class="task-meta small">
|
||||
{% if task.assigned_user %}
|
||||
<div class="text-muted mb-1">
|
||||
<i class="fas fa-user"></i> {{ task.assigned_user.username }}
|
||||
<i class="fas fa-user"></i> {{ task.assigned_user.display_name }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if task.due_date %}
|
||||
@@ -296,7 +296,7 @@
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td>{{ entry.user.username }}</td>
|
||||
<td>{{ entry.user.display_name }}</td>
|
||||
<td>{{ entry.start_time.strftime('%Y-%m-%d') }}</td>
|
||||
<td>
|
||||
{{ entry.start_time.strftime('%H:%M') }} -
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
<tbody>
|
||||
{% for entry in recent_entries %}
|
||||
<tr>
|
||||
<td>{{ entry.user.username }}</td>
|
||||
<td>{{ entry.user.display_name }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
|
||||
{{ entry.project.name }}
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if request.args.get('user_id')|int == user.id %}selected{% endif %}>
|
||||
{{ user.username }}
|
||||
{{ user.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -290,7 +290,7 @@
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td>{{ entry.user.username }}</td>
|
||||
<td>{{ entry.user.display_name }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
|
||||
{{ entry.project.name }}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
<option value="">All Users</option>
|
||||
{% for user in users %}
|
||||
<option value="{{ user.id }}" {% if request.args.get('user_id')|int == user.id %}selected{% endif %}>
|
||||
{{ user.username }}
|
||||
{{ user.display_name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
@@ -235,7 +235,7 @@
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td>{{ entry.user.username }}</td>
|
||||
<td>{{ entry.user.display_name }}</td>
|
||||
<td>
|
||||
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
|
||||
{{ entry.project.name }}
|
||||
|
||||
Reference in New Issue
Block a user