From 10bb02df573bd5f3ffd96fc58f4d1c746b3bff16 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 21 Jan 2026 23:28:14 +0000 Subject: [PATCH] User Management & Dashboard Analytics --- config/__pycache__/settings.cpython-311.pyc | Bin 5621 -> 5412 bytes config/settings.py | 19 +- core/__pycache__/admin.cpython-311.pyc | Bin 2919 -> 3328 bytes core/__pycache__/forms.cpython-311.pyc | Bin 5973 -> 6441 bytes core/__pycache__/models.cpython-311.pyc | Bin 7127 -> 8516 bytes core/__pycache__/urls.cpython-311.pyc | Bin 2496 -> 3033 bytes core/__pycache__/views.cpython-311.pyc | Bin 26068 -> 36657 bytes core/admin.py | 10 +- core/forms.py | 6 +- core/migrations/0002_invitation.py | 30 +++ .../0002_invitation.cpython-311.pyc | Bin 0 -> 2227 bytes core/models.py | 23 +- core/templates/core/accept_invite.html | 44 ++++ core/templates/core/dashboard.html | 29 +-- core/templates/core/invite_user.html | 30 +++ core/templates/core/settings.html | 27 ++- core/templates/core/user_list.html | 65 ++++++ core/urls.py | 7 + core/views.py | 201 +++++++++++++++++- requirements.txt | 2 + 20 files changed, 456 insertions(+), 37 deletions(-) create mode 100644 core/migrations/0002_invitation.py create mode 100644 core/migrations/__pycache__/0002_invitation.cpython-311.pyc create mode 100644 core/templates/core/accept_invite.html create mode 100644 core/templates/core/invite_user.html create mode 100644 core/templates/core/user_list.html diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index f2563874df027ab43c72ac3ec9cf06530e7dde8a..10b27e84c3acee0c123afecd9fe92d4b6fedaab9 100644 GIT binary patch delta 1458 zcmah}OHW!s6rQ=DJd~#(sG#^35PY;g>a!>iO=+|h(>RGG+H)n>g4hdP)Qv7o)2+D+ zH!h4ZG>w}kth?wxuscJ#Zql7ywA*$rO5u)aI$^#ubI$i2&b;nV|GCfj-eAxp+P*)3 zCH!hQGCHTesS!FxfB>gZ8WW7=F_+>9N3ANQN~?()BM$MHugPPCE@;1Lr?t{&jZJc4 zW8b!>?5vE4mWtH8w&DTx(MLy!+t!uIPy@Ajj^?EOrV%|ARWmf8RWeTNZvWM^uYFn4 zk$TVptE+>0r7o!=1~44TE2++ZhsVT?Y@RJHjPkVD3$*{YXd{>o5pOI9s{CeR0y8m# zg;;2}CJ|WAFlnH+fm$2bh#l;t5gMt-3=WFUGb~_eI#DMvaX>S1LJPQ?jECWhmJf)*Izj}=!7m>clU`#z@!O!pqI{ips$4968Z$3RFh`# zlNRVFF1jyn+FKz&+ITGl_v#QL9y%J3N7N49+9BD}<70+uLWmiSIDWivBjlsBdpmHaB8UF)z&qyD`n7G66GK|T8-)4XLHv1UO6ih4ERe1qV z${IhTnSoiQhV;Y3J9j_C_bXYa0r_olFeiOgx3vU$zsSKn@j;jj&^`zeG6dWT@kWrK4GxbY)S(W7uT zE4d%DS&iW8Jb5NxPQA>P?){M<+ETbe16ws!uA)wuiB^&)DN%?}#Mluf znxJP!pvfx7@jUc^1wBry4g@+s6)sL#WBJ`L0S0$e~(0ZJ%m-cU*5;_F0{ z@)BI)GcXHtvAXk7{%sN`z7PeBoU{KpUj!|BK`{cCVQJ_b>l3&FSCRL%*C|Q)MJU5{ zd@I9pC|5#Rk+hlMm!QgLVU^F}z0BiYfEs5a19~tMd=af1fra|!sa^{-{z8}GJ-&qg zTOsnwfYPJj#$g>chB%>zUmZX~#AduJehm@j7-|b{`(LSZ&FeA$cKpuxatv{21hEoB zygxFsXA@gJo7hS$=q`K^ZLD(^^yyhpH5T+C+=~YBRk%Mg;2(+AD5_Hnz95yr$Nsy- zLR%MAM}Zw)fd~8ssE^6=L!*KpzP7V(SWTO`!t`erP*w*0LSJq=o5Qh9_-{ z;p6Q|EGSJ;#ri_q^SlFhTi3y^yS0-deF}BEr3*{PjJjtZ$hxcd3F~2gOeI+3&@`DT zU60v~K|_WG%zLmWq{F^2%tPV+pJI+^3{~nDlNn0q{MoU8=#oF1`i!Rh=c!lSwRA}x zo5#s?mb;I}e^cmBemQ6O-{+Wi^EkD;iw*7Wc7M+OO1EwWsC5*_swfm<^NKd7d_7sgKdL$4fLT9=gg-u#W7nL6A bi*IthsqWXsZpjWJ4Nq6al9mhOma`sHjf>V+Qo z1E}N81D-T!q6hWj(W56!IMh9P^JD_X6Pn<>*`;dXRB2ki-GDfs@r;75l4iy_GNM(oDsBNQ;MD z_K+d$XD{k(tH=JTBOwn-OP+_3u!}%MFo^yPBlMC#?vT(KWQJCd8RmhBn0yg4auFF( z_Ed^sjlGjD1RVWr;ht5_l&xZcjrc~{guJE(xdR$xpXG!abEmuV^^-j??dB%EuFxF#1!B%GJfN-IiKCPIC5sY!K2vu57Jp^4q{KJu6=;@kGBBOyuQVNn z&o<<63@|WoIQ9`Jccp%P3E!-ouZ4yy=W3Cj${bv7!+;>oyY!)Ue3=IL!qF&MYs3Y6UVnC8TW3X{brSHwhe3p@dM1ZjHIsp I`$ds{13c0SCIA2c delta 858 zcmaJQTH%!HWob=r8CoVyKGKMUQ{rDgn8ZWw0yxk2#Pm!1Bb)kAH_uw~k*H-Wdk4N>x>A#MJTw;JyMD`^MRlwZ4dfayW2B>Lq!}K&hRSt#Z4@RZ zuA;mOM@DH{P6hWZF9+YI?t)_G59Q3|lw_}Ehh%y3M45fDlwU@3uNxe6!aKYvyzn=Q l))hj|GL+H*DV<#W24w5xKVSYI+UKw4IkgF;%`*f!>_4Yptm6Ox diff --git a/core/__pycache__/forms.cpython-311.pyc b/core/__pycache__/forms.cpython-311.pyc index 5e6a7ba77256a4dfe4bd0590e0ccc784cdae8f44..b6214bfd08c03a48b6db5c7365a32431a55a9d52 100644 GIT binary patch delta 478 zcmcbrx6+7jIWI340}!l=E6iNMJCRR<(PE>zAR`wOgF8ctKnp{P;A9y_X?B4WpLusr76}5yi$p*~ z0+8_2WG@m2aU?*5B#2-J61TX(hPY*>=A_)Jhj2Xe3Q9|Ev4Tx3k_V|&01=8HA{9mi z0vW||K%xN-Z}7-;xL)CrSzvR)!ea;P6$_6Gk?9{87%CZkn1ECOqc0L=>BsbufdNQ< z0h2&^UVjCU5U)Q-2uL=#f*{0hkhOlAf|K(^;}}yXpAePh$_0urg4{4^^Lx=SMi&l7 XMt`93*vSc@Q=&eC<-Xuh0k#GJPd{-7 delta 91 zcmZ2!bXAXUIWI340}#l>6lQ+qnaC%>D6~;skdZA#Aecc@aB~V{yD+2XAHZah0#F

TDDl diff --git a/core/__pycache__/models.cpython-311.pyc b/core/__pycache__/models.cpython-311.pyc index dd196050da002d5511a4d8fa4d522fa538007206..7428f1b77b24b58224809098a491f031f94e9318 100644 GIT binary patch delta 3097 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 delta 5870 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 B_-6nB 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 0000000000000000000000000000000000000000..251ce4ce67b30cf065f1512d2abe9a2affc8f425 GIT binary patch literal 2227 zcmbUiOKcNIbarj8*N&6ekl^43Y+71y5Q#&gC{budekK6|YDv`A{jAoWiL=Jub#~VY z32K!?4?S|IRE|BRMe(7>95`^~IFa@c&8bpP>B$IA9IC$A^}0|vb;kSV{pZblZ^plN zcSjM7hu>~!(;GNoNca{w^bVatSLcMULrzxwTA)hA=T`bV*eM^wXYko(`mM8P{;$8+Yuh~c-`tnw z=KF8*%0eNDKY)%q&mzlycR{NKONL2UESJlTDw?j@np$PiC4Ez~-Tq^uhF9`TT$;}Y zSxjx%hEg(#Y7@-5>ZVpx&28l#*=7-om|&{yfUiwr77dFix>_SFL~5#5J^mZ=;5N2; zU#SwkY_Bt^p=%or(6EhrL}!^?F_)Vd&rKDl#z)5|a^uC(QmHsOUL3nZCfCMBr;an+ zM}UPfSyLNT+dK)h$(aoqR3deL=5WcV)m42PxSbSPJ29Ta;kF1Oj2OBC zcb3?Y43kuOw!CDBw!xyVC2)${S<(CuwlFUM;H7XACar0timlt(0VXT1nWC_$qQEoI zs1m?qin7sAt6W)8Onxm{pS7jd>uM3?nAC}miC)?^`CG$!XI57hmDRfopDD|C<`-_U zSVu!67VDaGtGUeI7?VGFEH5m8g2#SswDFmGzs@FcPk)df8wiE;89o7QA*WJU4T9Zf1UgMU6UX zzkrX?3y2H7njKj;YGkBQZ0L4lgxn`3h|(O{GR%8cT`iFjUWJi=U(ONe=_}CFek%{x zw@s)&asj`q)?;Xe-Og-DD01#dzVKtzkuUrvI~T7V$}^5U(+UOpq}^Ms7>cC1?CeqE z%pZwZ{w~rD7G>Km%4v_xpRMmN8IiO`m?D7wonv4zmb%i?nv0t;~9WNvc~K;Qrb zPU4PulsHQ>W4}mFX6#^rW@ZmFvrcA~#^;U_DLQ!hQQjH6d>}oEIFpOcP~?h+ul1jt?91eb7{96wF&VB*P?Gkw#UUU8 +

+
+
+
+

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 %} + + +
+{% 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