diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index f256387..10b27e8 100644 Binary files a/config/__pycache__/settings.cpython-311.pyc and b/config/__pycache__/settings.cpython-311.pyc differ diff --git a/config/settings.py b/config/settings.py index 5232038..dbeac76 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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 diff --git a/core/__pycache__/admin.cpython-311.pyc b/core/__pycache__/admin.cpython-311.pyc index 90e9ac7..ba31260 100644 Binary files a/core/__pycache__/admin.cpython-311.pyc and b/core/__pycache__/admin.cpython-311.pyc differ diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 5e6a7ba..b6214bf 100644 Binary files a/core/__pycache__/forms.cpython-311.pyc and b/core/__pycache__/forms.cpython-311.pyc differ diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index dd19605..7428f1b 100644 Binary files a/core/__pycache__/models.cpython-311.pyc and b/core/__pycache__/models.cpython-311.pyc differ diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index d8c2e39..61533cc 100644 Binary files a/core/__pycache__/urls.cpython-311.pyc and b/core/__pycache__/urls.cpython-311.pyc differ diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 836029c..b95adf8 100644 Binary files a/core/__pycache__/views.cpython-311.pyc and b/core/__pycache__/views.cpython-311.pyc differ diff --git a/core/admin.py b/core/admin.py index 8a74819..ed63395 100644 --- a/core/admin.py +++ b/core/admin.py @@ -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') \ No newline at end of file + 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',) diff --git a/core/forms.py b/core/forms.py index 653a78a..df8bcc0 100644 --- a/core/forms.py +++ b/core/forms.py @@ -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'})) \ No newline at end of file + 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'})) \ No newline at end of file diff --git a/core/migrations/0002_invitation.py b/core/migrations/0002_invitation.py new file mode 100644 index 0000000..10063f9 --- /dev/null +++ b/core/migrations/0002_invitation.py @@ -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)), + ], + ), + ] diff --git a/core/migrations/__pycache__/0002_invitation.cpython-311.pyc b/core/migrations/__pycache__/0002_invitation.cpython-311.pyc new file mode 100644 index 0000000..251ce4c Binary files /dev/null and b/core/migrations/__pycache__/0002_invitation.cpython-311.pyc differ diff --git a/core/models.py b/core/models.py index 5ea4af8..ce6efa8 100644 --- a/core/models.py +++ b/core/models.py @@ -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}" \ No newline at end of file + 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}" \ No newline at end of file diff --git a/core/templates/core/accept_invite.html b/core/templates/core/accept_invite.html new file mode 100644 index 0000000..17f3460 --- /dev/null +++ b/core/templates/core/accept_invite.html @@ -0,0 +1,44 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block title %}Accept Invitation{% endblock %} + +{% block content %} +
+
+
+
+
+

Join {{ invitation.company.name }}

+
+
+ {% if user.is_authenticated and user.email != invitation.email %} + + {% else %} +

You have been invited to join {{ invitation.company.name }}. Please create an account or log in to accept the invitation.

+
+ {% csrf_token %} + {% if not user.is_authenticated %} +
+ {{ form.username|as_crispy_field }} +
+
+ {{ form.password|as_crispy_field }} +
+
+ {{ form.password2|as_crispy_field }} +
+ {% endif %} +
+ +
+
+ {% endif %} +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/core/templates/core/dashboard.html b/core/templates/core/dashboard.html index 42a2956..42564b4 100644 --- a/core/templates/core/dashboard.html +++ b/core/templates/core/dashboard.html @@ -67,7 +67,7 @@ Jobs - + Users @@ -106,20 +106,23 @@
-
0
-
Pending survey
+
{{ jobs_with_incomplete_folders }}
+
Jobs with Incomplete Folders
-
+
-
0
-
Critical Issues
-
-
-
-
-
0
-
Completed
+
Jobs by Status
+
    + {% for status in jobs_by_status %} +
  • + {{ status.status__name }} + {{ status.count }} +
  • + {% empty %} +
  • No jobs found.
  • + {% endfor %} +
@@ -163,4 +166,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/invite_user.html b/core/templates/core/invite_user.html new file mode 100644 index 0000000..4c8dc8c --- /dev/null +++ b/core/templates/core/invite_user.html @@ -0,0 +1,30 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block title %}Invite User{% endblock %} + +{% block content %} +
+
+
+
+
+

Invite User to {{ company.name }}

+
+
+
+ {% csrf_token %} +
+ {{ form.email|as_crispy_field }} +
+
+ Back to Settings + +
+
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/core/templates/core/settings.html b/core/templates/core/settings.html index daa25c5..e1f5f14 100644 --- a/core/templates/core/settings.html +++ b/core/templates/core/settings.html @@ -60,6 +60,31 @@ + +
+
+
+
+
User Management
+ Manage Users +
+ {% if invitations %} +
Pending Invitations:
+
    + {% for invite in invitations %} +
  • + {{ invite.email }} + Expires {{ invite.expires_at|date:"M d, Y" }} +
  • + {% endfor %} +
+ {% else %} +

No pending invitations.

+ {% endif %} + Invite New User +
+
+
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/user_list.html b/core/templates/core/user_list.html new file mode 100644 index 0000000..e63e18a --- /dev/null +++ b/core/templates/core/user_list.html @@ -0,0 +1,65 @@ +{% extends 'base.html' %} +{% load crispy_forms_tags %} + +{% block title %}Users in {{ company.name }}{% endblock %} + +{% block content %} +
+
+

Users in {{ company.name }}

+ Invite New User +
+ + {% if users_in_company %} +
+
+
+ + + + + + + + + + + {% for user_obj in users_in_company %} + + + + + + + {% endfor %} + +
UsernameEmailRoleActions
{{ user_obj.username }}{{ user_obj.email }} +
+ {% csrf_token %} + +
+
+ {% if user_obj != request.user %} +
+ {% csrf_token %} + +
+ {% else %} + (You) + {% endif %} +
+
+
+
+ {% else %} +
No users found in your company yet. Invite some!
+ {% endif %} + +
+ Back to Settings +
+
+{% endblock %} \ No newline at end of file diff --git a/core/urls.py b/core/urls.py index 3341915..1994a6c 100644 --- a/core/urls.py +++ b/core/urls.py @@ -29,4 +29,11 @@ urlpatterns = [ path('settings/status//delete/', views.status_delete, name='status_delete'), path('settings/folder/create/', views.folder_create, name='folder_create'), path('settings/folder//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//update-role/', views.user_update_role, name='user_update_role'), + path('settings/users//delete/', views.user_delete, name='user_delete'), + path('accept-invite//', views.accept_invite, name='accept_invite'), ] diff --git a/core/views.py b/core/views.py index d383351..bba0cee 100644 --- a/core/views.py +++ b/core/views.py @@ -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') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e22994c..d50b4f7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ Django==5.2.7 mysqlclient==2.2.7 python-dotenv==1.1.1 +django-crispy-forms +crispy-bootstrap5 \ No newline at end of file