Merge pull request #32 from DRYTRIX/feat-CommonNameForUser

feat: add real name support and fix task detail error
This commit is contained in:
Dries Peeters
2025-09-03 20:44:41 +02:00
committed by GitHub
23 changed files with 130 additions and 74 deletions
+5 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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')
+3 -3
View File
@@ -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(),
+6
View File
@@ -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>
+4
View File
@@ -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">
+1 -1
View File
@@ -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') }}">
+44 -41
View File
@@ -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');
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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 %}
+2 -2
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+1 -1
View File
@@ -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 %}
+4 -4
View File
@@ -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')
+1 -1
View File
@@ -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 }}
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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') }} -
+1 -1
View File
@@ -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 }}
+2 -2
View File
@@ -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 }}
+2 -2
View File
@@ -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 }}