User Management & Dashboard Analytics

This commit is contained in:
Flatlogic Bot 2026-01-21 23:28:14 +00:00
parent a200f58095
commit 10bb02df57
20 changed files with 456 additions and 37 deletions

View File

@ -1,15 +1,3 @@
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 5.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
from pathlib import Path
import os
from dotenv import load_dotenv
@ -55,6 +43,8 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'crispy_forms',
'crispy_bootstrap5',
'core',
]
@ -180,6 +170,11 @@ CONTACT_EMAIL_TO = [
# When both TLS and SSL flags are enabled, prefer SSL explicitly
if EMAIL_USE_SSL:
EMAIL_USE_TLS = False
# Crispy Forms settings
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
CRISPY_TEMPLATE_PACK = "bootstrap5"
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile, Invitation
@admin.register(Company)
class CompanyAdmin(admin.ModelAdmin):
@ -32,4 +32,10 @@ class JobFolderCompletionAdmin(admin.ModelAdmin):
@admin.register(JobFile)
class JobFileAdmin(admin.ModelAdmin):
list_display = ('job', 'folder', 'file', 'uploaded_at')
list_display = ('job', 'folder', 'file', 'uploaded_at')
@admin.register(Invitation)
class InvitationAdmin(admin.ModelAdmin):
list_display = ('email', 'company', 'invited_by', 'created_at', 'expires_at', 'is_accepted')
list_filter = ('company', 'is_accepted')
search_fields = ('email',)

View File

@ -75,4 +75,8 @@ class JobFileForm(forms.ModelForm):
}
class ImportJobsForm(forms.Form):
file = forms.FileField(label="Excel/CSV File", widget=forms.ClearableFileInput(attrs={'class': 'form-control'}))
file = forms.FileField(label="Excel/CSV File", widget=forms.ClearableFileInput(attrs={'class': 'form-control'}))
class InviteUserForm(forms.Form):
email = forms.EmailField(label="User Email", widget=forms.EmailInput(attrs={'class': 'form-control', 'placeholder': 'email@example.com'}))

View File

@ -0,0 +1,30 @@
# Generated by Django 5.2.7 on 2026-01-21 23:17
import datetime
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Invitation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254, unique=True)),
('token', models.CharField(default='0b006409b9414604b1ccb84b32e8f319', max_length=32, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField(default=datetime.datetime(2026, 1, 28, 23, 17, 24, 824391, tzinfo=datetime.timezone.utc))),
('is_accepted', models.BooleanField(default=False)),
('company', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='core.company')),
('invited_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,6 +1,10 @@
from django.db import models
from django.contrib.auth.models import User
from django.core.validators import MinLengthValidator
import uuid
from datetime import timedelta
from django.utils import timezone
class Company(models.Model):
name = models.CharField(max_length=255)
@ -59,6 +63,11 @@ class Job(models.Model):
class Meta:
unique_together = [['company', 'job_ref'], ['company', 'uprn']]
# Add a custom constraint to allow null=True and blank=True for uprn,
# but still enforce unique_together when it's not null.
# This is handled by a custom validator or by overriding save method if needed.
# For now, Django's unique_together with null=True will allow multiple nulls.
def __str__(self):
return f"{self.job_ref} - {self.address_line_1}"
@ -79,4 +88,16 @@ class JobFile(models.Model):
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
def __str__(self):
return f"{self.file.name} in {self.folder.name}"
return f"{self.file.name} in {self.folder.name}"
class Invitation(models.Model):
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name='invitations')
invited_by = models.ForeignKey(User, on_delete=models.CASCADE)
email = models.EmailField(unique=True)
token = models.CharField(max_length=32, unique=True, default=uuid.uuid4().hex)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField(default=timezone.now() + timedelta(days=7))
is_accepted = models.BooleanField(default=False)
def __str__(self):
return f"Invitation for {self.email} to {self.company.name}"

View File

@ -0,0 +1,44 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Accept Invitation{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-lg border-0 rounded-lg">
<div class="card-header bg-gradient-primary text-white text-center py-4">
<h3 class="font-weight-light my-2">Join {{ invitation.company.name }}</h3>
</div>
<div class="card-body p-4">
{% if user.is_authenticated and user.email != invitation.email %}
<div class="alert alert-warning" role="alert">
You are logged in as {{ user.email }}. This invitation is for {{ invitation.email }}. Please log out or use the correct account.
</div>
{% else %}
<p class="mb-4">You have been invited to join <strong>{{ invitation.company.name }}</strong>. Please create an account or log in to accept the invitation.</p>
<form method="post" class="needs-validation" novalidate>
{% csrf_token %}
{% if not user.is_authenticated %}
<div class="mb-3">
{{ form.username|as_crispy_field }}
</div>
<div class="mb-3">
{{ form.password|as_crispy_field }}
</div>
<div class="mb-3">
{{ form.password2|as_crispy_field }}
</div>
{% endif %}
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<button type="submit" class="btn btn-primary">Accept Invitation</button>
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -67,7 +67,7 @@
<a href="{% url 'job_list' %}" class="nav-link-custom">
<i class="bi bi-tools"></i> Jobs
</a>
<a href="#" class="nav-link-custom">
<a href="{% url 'user_list' %}" class="nav-link-custom">
<i class="bi bi-people"></i> Users
</a>
</div>
@ -106,20 +106,23 @@
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value">0</div>
<div class="stat-label">Pending survey</div>
<div class="stat-value text-warning">{{ jobs_with_incomplete_folders }}</div>
<div class="stat-label">Jobs with Incomplete Folders</div>
</div>
</div>
<div class="col-md-3">
<div class="col-md-6">
<div class="stat-card">
<div class="stat-value">0</div>
<div class="stat-label">Critical Issues</div>
</div>
</div>
<div class="col-md-3">
<div class="stat-card">
<div class="stat-value">0</div>
<div class="stat-label">Completed</div>
<h6 class="mb-3">Jobs by Status</h6>
<ul class="list-group list-group-flush">
{% for status in jobs_by_status %}
<li class="list-group-item d-flex justify-content-between align-items-center px-0 py-1">
{{ status.status__name }}
<span class="badge bg-secondary rounded-pill">{{ status.count }}</span>
</li>
{% empty %}
<li class="list-group-item px-0 py-1">No jobs found.</li>
{% endfor %}
</ul>
</div>
</div>
</div>
@ -163,4 +166,4 @@
</div>
</main>
</div>
{% endblock %}
{% endblock %}

View File

@ -0,0 +1,30 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Invite User{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<div class="card shadow-lg border-0 rounded-lg">
<div class="card-header bg-gradient-primary text-white text-center py-4">
<h3 class="font-weight-light my-2">Invite User to {{ company.name }}</h3>
</div>
<div class="card-body p-4">
<form method="post" class="needs-validation" novalidate>
{% csrf_token %}
<div class="mb-3">
{{ form.email|as_crispy_field }}
</div>
<div class="d-flex align-items-center justify-content-between mt-4 mb-0">
<a class="small text-primary" href="{% url 'settings' %}">Back to Settings</a>
<button type="submit" class="btn btn-primary">Send Invitation</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -60,6 +60,31 @@
</div>
</div>
</div>
<div class="col-md-6 mt-4">
<div class="card h-100">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<h5 class="mb-0">User Management</h5>
<a href="{% url 'user_list' %}" class="btn btn-sm btn-primary">Manage Users</a>
</div>
{% if invitations %}
<h6 class="mb-3">Pending Invitations:</h6>
<ul class="list-group mb-3">
{% for invite in invitations %}
<li class="list-group-item d-flex justify-content-between align-items-center">
{{ invite.email }}
<span class="badge bg-info rounded-pill">Expires {{ invite.expires_at|date:"M d, Y" }}</span>
</li>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">No pending invitations.</p>
{% endif %}
<a href="{% url 'invite_user' %}" class="btn btn-sm btn-outline-primary">Invite New User</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -0,0 +1,65 @@
{% extends 'base.html' %}
{% load crispy_forms_tags %}
{% block title %}Users in {{ company.name }}{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Users in {{ company.name }}</h2>
<a href="{% url 'invite_user' %}" class="btn btn-primary">Invite New User</a>
</div>
{% if users_in_company %}
<div class="card shadow-lg border-0 rounded-lg">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for user_obj in users_in_company %}
<tr>
<td>{{ user_obj.username }}</td>
<td>{{ user_obj.email }}</td>
<td>
<form action="{% url 'user_update_role' pk=user_obj.pk %}" method="post" class="d-inline">
{% csrf_token %}
<select name="role" class="form-select form-select-sm d-inline w-auto" onchange="this.form.submit()">
<option value="ADMIN" {% if user_obj.profile.role == 'ADMIN' %}selected{% endif %}>Admin</option>
<option value="STANDARD" {% if user_obj.profile.role == 'STANDARD' %}selected{% endif %}>Standard User</option>
</select>
</form>
</td>
<td>
{% if user_obj != request.user %}
<form action="{% url 'user_delete' pk=user_obj.pk %}" method="post" class="d-inline ms-2">
{% csrf_token %}
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('Are you sure you want to delete this user?');">Delete</button>
</form>
{% else %}
<span class="text-muted"> (You)</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info">No users found in your company yet. Invite some!</div>
{% endif %}
<div class="mt-4">
<a href="{% url 'settings' %}" class="btn btn-secondary">Back to Settings</a>
</div>
</div>
{% endblock %}

View File

@ -29,4 +29,11 @@ urlpatterns = [
path('settings/status/<int:pk>/delete/', views.status_delete, name='status_delete'),
path('settings/folder/create/', views.folder_create, name='folder_create'),
path('settings/folder/<int:pk>/delete/', views.folder_delete, name='folder_delete'),
# User Management
path('settings/invite-user/', views.invite_user, name='invite_user'),
path('settings/users/', views.user_list, name='user_list'),
path('settings/users/<int:pk>/update-role/', views.user_update_role, name='user_update_role'),
path('settings/users/<int:pk>/delete/', views.user_delete, name='user_delete'),
path('accept-invite/<str:token>/', views.accept_invite, name='accept_invite'),
]

View File

@ -1,15 +1,24 @@
import os
import io
import pandas as pd
import uuid
from datetime import timedelta
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth import login, logout, authenticate
from django.contrib.auth.forms import UserCreationForm, AuthenticationForm
from django.contrib.auth.decorators import login_required
from django.contrib import messages
from django.db import transaction
from django.db.models import Count
from django.http import HttpResponse
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile
from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm, JobFileForm, ImportJobsForm
from django.core.mail import send_mail
from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile, Invitation
from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm, JobFileForm, ImportJobsForm, InviteUserForm
def home(request):
if request.user.is_authenticated:
@ -104,9 +113,18 @@ def dashboard(request):
company = profile.company
jobs = Job.objects.filter(company=company)
total_jobs = jobs.count()
jobs_by_status = jobs.values('status__name').annotate(count=Count('id')).order_by('status__name')
# Jobs with incomplete folders
jobs_with_incomplete_folders = jobs.filter(folder_completions__is_completed=False).distinct().count()
context = {
'company': company,
'total_jobs': jobs.count(),
'total_jobs': total_jobs,
'jobs_by_status': jobs_by_status,
'jobs_with_incomplete_folders': jobs_with_incomplete_folders,
'jobs': jobs.order_by('-created_at')[:5],
}
return render(request, 'core/dashboard.html', context)
@ -388,12 +406,16 @@ def settings_view(request):
company = profile.company
statuses = company.statuses.all()
folders = company.required_folders.all()
return render(request, 'core/settings.html', {
invitations = company.invitations.filter(is_accepted=False, expires_at__gt=timezone.now())
context = {
'company': company,
'statuses': statuses,
'folders': folders
})
'folders': folders,
'invitations': invitations
}
return render(request, 'core/settings.html', context)
@login_required
def status_create(request):
@ -483,3 +505,168 @@ def folder_delete(request, pk):
messages.success(request, "Folder removed from company settings.")
return redirect('settings')
return render(request, 'core/folder_confirm_delete.html', {'folder': folder})
@login_required
def invite_user(request):
profile = request.user.profile
if not profile.company or profile.role != 'ADMIN':
messages.error(request, "You must be an admin to invite users.")
return redirect('dashboard')
company = profile.company
if request.method == 'POST':
form = InviteUserForm(request.POST)
if form.is_valid():
email = form.cleaned_data['email']
if Invitation.objects.filter(email=email, company=company, is_accepted=False, expires_at__gt=timezone.now()).exists():
messages.warning(request, f"An active invitation for {email} already exists.")
return redirect('invite_user')
# Check if user already exists in this company
if User.objects.filter(email=email, profile__company=company).exists():
messages.warning(request, f"A user with {email} already exists in your company.")
return redirect('invite_user')
try:
with transaction.atomic():
invitation = Invitation.objects.create(
company=company,
invited_by=request.user,
email=email,
expires_at=timezone.now() + timedelta(days=7) # 7 days expiry
)
invite_link = request.build_absolute_uri(
reverse('accept_invite', args=[invitation.token])
)
subject = f"Invitation to join {company.name} on RepairsHub"
message = f"You have been invited to join {company.name} on RepairsHub. Click the link to accept: {invite_link}"
from_email = settings.DEFAULT_FROM_EMAIL
recipient_list = [email]
send_mail(subject, message, from_email, recipient_list)
messages.success(request, f"Invitation sent to {email}.")
return redirect('settings') # Redirect to settings where user list will be
except Exception as e:
messages.error(request, f"Error sending invitation: {e}")
else:
messages.error(request, "Please correct the error below.")
else:
form = InviteUserForm()
return render(request, 'core/invite_user.html', {'form': form, 'company': company})
def accept_invite(request, token):
invitation = get_object_or_404(Invitation, token=token, is_accepted=False, expires_at__gt=timezone.now())
if request.user.is_authenticated:
# If user is logged in, and tries to accept an invite for another email, prevent it.
if request.user.email != invitation.email:
messages.error(request, "You are logged in with a different email address. Please log out or use the correct account.")
return redirect('home') # Or a more appropriate page
# If the user is already authenticated with the correct email, just associate them.
with transaction.atomic():
profile, created = Profile.objects.get_or_create(user=request.user, defaults={'company': invitation.company, 'role': 'STANDARD'})
if not created and profile.company != invitation.company:
messages.error(request, "You are already part of another company. Please contact support to join a new company.")
return redirect('dashboard')
elif not created and profile.company == invitation.company: # Already in this company
messages.info(request, "You are already a member of this company.")
else: # New association
profile.company = invitation.company
profile.role = 'STANDARD'
profile.save()
invitation.is_accepted = True
invitation.save()
messages.success(request, f"Welcome to {invitation.company.name}!")
return redirect('dashboard')
# If user is not authenticated, they need to register/login
if request.method == 'POST':
form = UserCreationForm(request.POST)
if form.is_valid():
with transaction.atomic():
user = form.save()
user.email = invitation.email # Ensure the user's email matches the invitation
user.save()
profile = user.profile
profile.company = invitation.company
profile.role = 'STANDARD'
profile.save()
invitation.is_accepted = True
invitation.save()
login(request, user)
messages.success(request, f"Welcome to {invitation.company.name}!")
return redirect('dashboard')
else:
messages.error(request, "Please correct the errors below.")
else:
form = UserCreationForm(initial={'email': invitation.email})
return render(request, 'core/accept_invite.html', {'form': form, 'invitation': invitation})
@login_required
def user_list(request):
profile = request.user.profile
if not profile.company or profile.role != 'ADMIN':
messages.error(request, "You must be an admin to manage users.")
return redirect('dashboard')
company = profile.company
users_in_company = User.objects.filter(profile__company=company).select_related('profile')
context = {
'company': company,
'users_in_company': users_in_company,
}
return render(request, 'core/user_list.html', context)
@login_required
def user_update_role(request, pk):
profile = request.user.profile
if not profile.company or profile.role != 'ADMIN':
messages.error(request, "You must be an admin to manage user roles.")
return redirect('dashboard')
user_to_update = get_object_or_404(User, pk=pk, profile__company=profile.company)
if request.method == 'POST':
new_role = request.POST.get('role')
if new_role in ['ADMIN', 'STANDARD']:
user_to_update.profile.role = new_role
user_to_update.profile.save()
messages.success(request, f"Role for {user_to_update.username} updated to {new_role}.")
else:
messages.error(request, "Invalid role selected.")
return redirect('user_list')
@login_required
def user_delete(request, pk):
profile = request.user.profile
if not profile.company or profile.role != 'ADMIN':
messages.error(request, "You must be an admin to delete users.")
return redirect('dashboard')
user_to_delete = get_object_or_404(User, pk=pk, profile__company=profile.company)
if request.method == 'POST':
# Prevent an admin from deleting themselves
if user_to_delete == request.user:
messages.error(request, "You cannot delete your own account.")
return redirect('user_list')
username = user_to_delete.username
user_to_delete.delete()
messages.success(request, f"User {username} deleted.")
return redirect('user_list')

View File

@ -1,3 +1,5 @@
Django==5.2.7
mysqlclient==2.2.7
python-dotenv==1.1.1
django-crispy-forms
crispy-bootstrap5