From a200f58095f4dfb358360f03de99da54c9ad458d Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Wed, 21 Jan 2026 23:12:42 +0000 Subject: [PATCH] File & Photo Uploads & Excel Import/Export --- config/__pycache__/settings.cpython-311.pyc | Bin 5552 -> 5621 bytes config/__pycache__/urls.cpython-311.pyc | Bin 1557 -> 1663 bytes config/settings.py | 6 +- config/urls.py | 1 + core/__pycache__/forms.cpython-311.pyc | Bin 5002 -> 5973 bytes core/__pycache__/urls.cpython-311.pyc | Bin 2055 -> 2496 bytes core/__pycache__/views.cpython-311.pyc | Bin 17963 -> 26068 bytes core/forms.py | 13 +- core/templates/core/job_detail.html | 117 +++++++++++--- core/templates/core/job_import.html | 72 +++++++++ core/templates/core/job_list.html | 42 +++-- core/urls.py | 45 +++--- core/views.py | 165 ++++++++++++++++++-- 13 files changed, 389 insertions(+), 72 deletions(-) create mode 100644 core/templates/core/job_import.html diff --git a/config/__pycache__/settings.cpython-311.pyc b/config/__pycache__/settings.cpython-311.pyc index 96bce5584823cd4ebfccfc40c2b36df8beaee7db..f2563874df027ab43c72ac3ec9cf06530e7dde8a 100644 GIT binary patch delta 518 zcmdm>{Z*THIWI340}w2TDa@QWkyn!O(MI);O!XDDJE6i3=B~!Obn^aSzu)# zr75N*YFRY)cQ^b_w5~ZEu8fB2e9L%8UwuyBv2Y(e?ZfZ(qqW&#bFui#$Z#g65y2+ya zjf{qyr}H0T^u5LD>+0g^7#|wsbBhbY4D$C6DKY`tQe+AwZZT)(l@ytQxE3J75=2;m z2x|~w3nXr_78m4XmJ~To?iV!V$^?plLbLe&=2L>(m;`u58#r(9iq2ra$SdE#^?_}3 Iy@(+r09>D9rT_o{ delta 415 zcmeyWy+NCIIWI340}xbw%g=O~$ScWsWuy8>rYI(cROT$O1V}Q)IK^Zc69dC)AclY_ zWi%D0DO_kORDdc}(NvkGm``qCmJL-y6Sqj=MboX0ros|Kg$9}mtBK!ig*DLxty63! z{;+1$nry(Lpb@2=V&B4mrotgr8`bIsI+G8wi!h})PQJq;FRYuYRm7Cy6s4Ks9Hp1S z9L%8UvYC%{F309wyyc9Ht0o)qH!|vP-ok%~(X7ZA7&b*FK;jm2W?o5=DTr$hA}m0J sC5W&B5jH^L7He@qPG(7w!{oz)hFobt5k?>`zPI_G;5MesM@0-60o{8}6#xJL diff --git a/config/__pycache__/urls.cpython-311.pyc b/config/__pycache__/urls.cpython-311.pyc index 0b85e94ece283a83ff1af1d71f1b265c943eb37a..2da1a1c894a18ffec82ef9a7a7085f5e6f68acaf 100644 GIT binary patch delta 165 zcmbQr^Ph)zIWI340}w2YDa@>y$ScWcH&J~7qu<06DPpNSObn@5f``$Qrxz|}FeRQbJSJ(&qPJD5;Myt*? z!AN9bqQ`vsz1qx3#kSb{Qf_035YnkZ@2-j?`^z6$<%Ryr;n+|P5k2gz^aNHq;c7!lO2f|AThXL3{ zMU`+Pw1?dj7v9KXlW&Z^jLoKfbR&n5z{XL8EJ8Qz=b^-HQkgDy9^cby=&@#1GR|mL z#XgZUfslT7N50t4mwmpFUR(nKU3kn=(RTx5dqK#-jaqa$bd{TgE^^Pdar0movVMft zt~-XVG%JhZ=w@X&Av$5*fb+3RzBF#xR@r5r$_E0uuWd{zLnL_nk&Ulfc1h+8E^``S z0$#`iJRf?JeVaT%+SyjJkEEEGI!ey4S5v(b&yd{4l~jz7EVI-yImv!j+ckqv0&qeA z*(~j1f2bc`%0i&a&C<~-nnSn@;3ta3vR!hVvSr)FqE6@-unwmGJ3⪼m{%Wb?e80 z^dZd1gz`^Kj6e;P*IN&O!Us|>`!lMsza(*1gp!hW)u#px9)gyTbQ#1tyHZ9-cmOZTni z>gm28&)=W+PF(WR`OS2`p3d)tLodWvWxRtd#Wjt6^Li3`C<1qI0RMw2$d%#QyD)k2 zdOQJeN3etfF@-`XBv|432=4(f36DiK9Xq`8gZLes-#vJOt00*Y_$d3y(dxAtb-_&s z{l1yZ-GJj7JUf0li(WMOsfT>gF3niBFI}&gbCzp7n*^BSehep%R4m)1S0P@ZQ-IBd zTtp5b50CREz*F`rhgMI0@wV4>+#4Rdf2=+{<`qg_dC8lk-mtSd?9_)HPjNRDx30Li z&fy)Hh3gr*>PzN)X>qQ`zc)S`)Y>O09=#vS*G$XCy#^md{x#v1%(D@__9MKT%j<+V zSQsm0H+s5SGnZ}aEjkAwrm{0l*q2&+&pA){UqAMX_S2FOV delta 1071 zcmah|J!lj`6yDkF?d|>Fp-Ju%V>U4{nHVn`K}1DPq7gAU19uqGcr3SLqS<71c7q~< z1g$Ir$15x?EF@KmvkW2@7Ghyzv)EWhn?tY(2+o@rgKLpx`Iz^;pZVs^o6p5(L+Mwk zR9q2XefHk%)kGtmSKld(;iFlisLB<^)}AVMgpoT@FiB^|ken{K=wB`-IYV%|{lusV&@zk|1)+l*JsqZ1*&OY_6Hl2InE@Ju`oW7<_+l5yBD3h-7RgZJ7xNx|Jnel8IA1hp7F^%;=$`H}jfE9PvFLva zzeakE8!_CY9DZ!J>G2KMxMDukfMo2Hb$<~YGf}v`VtUJtFW_HcCd@E?1 zXTGR6B})nLQ~{>RO}L($okArv8a}5KI)NEg0YR!(t2>tG)tTeeYLxI9!Fu=vO57C4 zixS{R?om?S3EE|vgynX6DTqzGHghJ$v)LJKIXLoeWZJ(y+BVKFx%P^~F7P=~1o!ZM gD$r{09ys?&+2?O*V0GkSy5o6T86*c=2y!TY0|}tgUH||9 diff --git a/core/__pycache__/urls.cpython-311.pyc b/core/__pycache__/urls.cpython-311.pyc index 4869d19724ee2f8be16fd3a0858c622992da76cd..d8c2e394107e62c07603a95cfb47ac132bbe3165 100644 GIT binary patch delta 538 zcmZn{I3TRCoR^o20SFvp3Nu%;F)%y^abQ3S%J^)>yisEb)5Hy|{Fr5RfhwC6!_r%%EvB`5IFq zCtq@YZb4#RrS4)2X2yE0to)>6eVfd@603r2JN?pvoczQT-L%Y{RES_&eojhiQG8~K zo&GI;pxStdig=KUDh*W4DXBTBC8@}ofr^l{LgXP@dB9pzbt($-i%Rrwae<6Z1#_w( zQkl6(G7!#WbCw^B(vuBX3oVO8fPO0y1reYiD*^@5E#BhPl9J54^y2uk%+&HCP>>XX z!nsHqM96>$*~!OP1Gv?JOhzCsc3_&U$krk8fQ6&MEXfyHlCQ8NPkzTH aGx;-{(Bw4sN&MzA{7enpAXuadv=RW4cbUfk delta 186 zcmX>g+%BN8oR^o20SM~D3p2x685kaeI4~dvWqe-6v{7RT6IZ%Wlvs*pFoUMnDP6uzqJ0pIpLLsCtXHIJKlCGcUb3zAQ7fyhs$Ny$EDRkvNEu01=Xt<=6wb zm4Qq~ATGYoIJuI&L;MB{M}td;#01kT!fGqxFR<8NWU;-%Vmn!qW7%YR&Pn{b;`~ev L+#pz_2-F4uK&>o0 diff --git a/core/__pycache__/views.cpython-311.pyc b/core/__pycache__/views.cpython-311.pyc index 4ee1212eefa9df8dd5d71794644b6cab53286434..836029c2d65dd489153b078c4509a705f490802e 100644 GIT binary patch literal 26068 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 diff --git a/core/forms.py b/core/forms.py index f26ed7b..653a78a 100644 --- a/core/forms.py +++ b/core/forms.py @@ -1,5 +1,5 @@ from django import forms -from .models import Company, JobStatus, RequiredFolder, Job +from .models import Company, JobStatus, RequiredFolder, Job, JobFile class CompanyForm(forms.ModelForm): class Meta: @@ -65,3 +65,14 @@ class JobForm(forms.ModelForm): if not uprn: return None return uprn + +class JobFileForm(forms.ModelForm): + class Meta: + model = JobFile + fields = ['file'] + widgets = { + 'file': forms.ClearableFileInput(attrs={'class': 'form-control'}), + } + +class ImportJobsForm(forms.Form): + file = forms.FileField(label="Excel/CSV File", widget=forms.ClearableFileInput(attrs={'class': 'form-control'})) \ No newline at end of file diff --git a/core/templates/core/job_detail.html b/core/templates/core/job_detail.html index bee1474..61fd9eb 100644 --- a/core/templates/core/job_detail.html +++ b/core/templates/core/job_detail.html @@ -1,4 +1,5 @@ {% extends 'base.html' %} +{% load static %} {% block title %}Job: {{ job.job_ref }} - RepairsHub{% endblock %} {% block content %}
@@ -81,24 +82,94 @@
{% for completion in folder_completions %} -
-
-
-
-
{{ completion.folder.name }}
-

- 0 Files -

+
+
+
+
+
+
{{ completion.folder.name }}
+

+ {{ completion.files_list.count }} Files uploaded +

+
+
+ +
+ {% csrf_token %} + +
+
-
+ + {% if completion.files_list %} +
+ + + + + + + + + + + {% for job_file in completion.files_list %} + + + + + + + {% endfor %} + +
File NameUploaded ByDateActions
+ + {{ job_file.file.name|cut:"job_files/" }} + + {{ job_file.uploaded_by.username }}{{ job_file.uploaded_at|date:"d/m/y H:i" }} + + + +
+
+ {% else %} +
+

No files uploaded yet.

+
+ {% endif %} +
+
+
+ + +
-
+
-
Quick Export
-

Download this job details for offline reporting.

- +
Quick Export
+

Download this job details for offline reporting.

+ + Export to Excel +
-{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/templates/core/job_import.html b/core/templates/core/job_import.html new file mode 100644 index 0000000..7b5ba71 --- /dev/null +++ b/core/templates/core/job_import.html @@ -0,0 +1,72 @@ +{% extends 'base.html' %} +{% block title %}Import Jobs - RepairsHub{% endblock %} +{% block content %} +
+
+
+ + +
+
+

Import Jobs

+

Upload an Excel or CSV file to bulk create jobs.

+
+
+ {% if messages %} + {% for message in messages %} + + {% endfor %} + {% endif %} + +
+
Instructions
+
    +
  • Supported formats: .xlsx, .xls, .csv
  • +
  • The following columns are recognized: +
      +
    • Job Ref (Required, Unique)
    • +
    • UPRN (Optional)
    • +
    • Address 1 (Required)
    • +
    • Postcode (Required)
    • +
    • Status (Optional, defaults to starting status)
    • +
    +
  • +
  • Duplicate Job Ref will update existing jobs.
  • +
+
+ +
+ {% csrf_token %} +
+ + {{ form.file }} + {% if form.file.errors %} +
{{ form.file.errors }}
+ {% endif %} +
+ +
+ + Cancel +
+
+
+
+ +
+

Need a template? Export existing jobs to see the required format.

+
+
+
+
+{% endblock %} diff --git a/core/templates/core/job_list.html b/core/templates/core/job_list.html index 7647c69..1eec283 100644 --- a/core/templates/core/job_list.html +++ b/core/templates/core/job_list.html @@ -7,9 +7,20 @@

Repair Jobs

Manage and track all repairs for {{ company.name }}

- - Create New Job - +
+ + + Create New Job + +
{% if messages %} @@ -21,7 +32,7 @@ {% endfor %} {% endif %} -
+
@@ -44,7 +55,7 @@ {% endif %} {% empty %} - @@ -74,4 +92,4 @@ -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/core/urls.py b/core/urls.py index 44a0956..3341915 100644 --- a/core/urls.py +++ b/core/urls.py @@ -2,26 +2,31 @@ from django.urls import path from . import views urlpatterns = [ - path("", views.home, name="home"), - path("register/", views.register_view, name="register"), - path("login/", views.login_view, name="login"), - path("logout/", views.logout_view, name="logout"), - path("setup/", views.company_setup, name="company_setup"), - path("dashboard/", views.dashboard, name="dashboard"), + path('', views.home, name='home'), + path('register/', views.register_view, name='register'), + path('login/', views.login_view, name='login'), + path('logout/', views.logout_view, name='logout'), - # Job CRUD - path("jobs/", views.job_list, name="job_list"), - path("jobs/create/", views.job_create, name="job_create"), - path("jobs//", views.job_detail, name="job_detail"), - path("jobs//edit/", views.job_update, name="job_update"), - path("jobs//delete/", views.job_delete, name="job_delete"), - path("jobs//toggle-folder//", views.toggle_folder_completion, name="toggle_folder_completion"), + path('company-setup/', views.company_setup, name='company_setup'), + path('dashboard/', views.dashboard, name='dashboard'), + + # Jobs + path('jobs/', views.job_list, name='job_list'), + path('jobs/create/', views.job_create, name='job_create'), + path('jobs//', views.job_detail, name='job_detail'), + path('jobs//edit/', views.job_update, name='job_update'), + path('jobs//delete/', views.job_delete, name='job_delete'), + path('jobs//toggle-folder//', views.toggle_folder_completion, name='toggle_folder_completion'), + path('jobs//upload-file//', views.job_upload_file, name='job_upload_file'), + path('jobs//delete-file//', views.job_delete_file, name='job_delete_file'), + path('jobs/export/', views.job_export, name='job_export'), + path('jobs/import/', views.job_import, name='job_import'), # Settings - path("settings/", views.settings_view, name="settings"), - path("settings/status/create/", views.status_create, name="status_create"), - path("settings/status//edit/", views.status_update, name="status_update"), - 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"), -] \ No newline at end of file + path('settings/', views.settings_view, name='settings'), + path('settings/status/create/', views.status_create, name='status_create'), + path('settings/status//edit/', views.status_update, name='status_update'), + 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'), +] diff --git a/core/views.py b/core/views.py index 1af256f..d383351 100644 --- a/core/views.py +++ b/core/views.py @@ -1,13 +1,15 @@ import os -import platform +import io +import pandas as pd 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 .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion -from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm +from django.http import HttpResponse +from .models import Company, Profile, JobStatus, RequiredFolder, Job, JobFolderCompletion, JobFile +from .forms import CompanyForm, JobStatusForm, RequiredFolderForm, JobForm, JobFileForm, ImportJobsForm def home(request): if request.user.is_authenticated: @@ -46,7 +48,6 @@ def logout_view(request): @login_required def company_setup(request): - # Check if user already has a company if request.user.profile.company: return redirect('dashboard') @@ -59,14 +60,11 @@ def company_setup(request): if company_form.is_valid() and status_names and default_status_idx is not None: with transaction.atomic(): company = company_form.save() - - # Link user to company as ADMIN profile = request.user.profile profile.company = company profile.role = 'ADMIN' profile.save() - # Create statuses for i, name in enumerate(status_names): if name.strip(): JobStatus.objects.create( @@ -76,7 +74,6 @@ def company_setup(request): order=i ) - # Create folders for name in folder_names: if name.strip(): RequiredFolder.objects.create( @@ -110,7 +107,7 @@ def dashboard(request): context = { 'company': company, 'total_jobs': jobs.count(), - 'jobs': jobs.order_by('-created_at')[:5], # Recent jobs + 'jobs': jobs.order_by('-created_at')[:5], } return render(request, 'core/dashboard.html', context) @@ -143,7 +140,6 @@ def job_create(request): job.company = company job.save() - # Initialize folder completions for folder in company.required_folders.all(): JobFolderCompletion.objects.get_or_create(job=job, folder=folder) @@ -167,16 +163,22 @@ def job_detail(request, pk): company = profile.company job = get_object_or_404(Job, pk=pk, company=company) - # Ensure all required folders have completion records (in case new ones were added later) for folder in company.required_folders.all(): JobFolderCompletion.objects.get_or_create(job=job, folder=folder) folder_completions = job.folder_completions.all().select_related('folder') + # Get files for each folder + for completion in folder_completions: + completion.files_list = job.files.filter(folder=completion.folder) + + file_form = JobFileForm() + return render(request, 'core/job_detail.html', { 'job': job, 'folder_completions': folder_completions, - 'company': company + 'company': company, + 'file_form': file_form }) @login_required @@ -238,6 +240,141 @@ def toggle_folder_completion(request, pk, folder_id): messages.success(request, f"Folder '{completion.folder.name}' status updated.") return redirect('job_detail', pk=job.pk) +@login_required +def job_upload_file(request, pk, folder_id): + profile = request.user.profile + company = profile.company + job = get_object_or_404(Job, pk=pk, company=company) + folder = get_object_or_404(RequiredFolder, pk=folder_id, company=company) + + if request.method == 'POST': + form = JobFileForm(request.POST, request.FILES) + if form.is_valid(): + job_file = form.save(commit=False) + job_file.job = job + job_file.folder = folder + job_file.uploaded_by = request.user + job_file.save() + messages.success(request, f"File uploaded to {folder.name}.") + else: + messages.error(request, "Error uploading file.") + + return redirect('job_detail', pk=job.pk) + +@login_required +def job_delete_file(request, pk, file_id): + profile = request.user.profile + company = profile.company + job = get_object_or_404(Job, pk=pk, company=company) + job_file = get_object_or_404(JobFile, pk=file_id, job=job) + + job_file.file.delete() + job_file.delete() + messages.success(request, "File deleted.") + return redirect('job_detail', pk=job.pk) + +@login_required +def job_export(request): + profile = request.user.profile + company = profile.company + jobs = Job.objects.filter(company=company) + + data = [] + for job in jobs: + data.append({ + 'Job Ref': job.job_ref, + 'UPRN': job.uprn, + 'Address 1': job.address_line_1, + 'Address 2': job.address_line_2, + 'Address 3': job.address_line_3, + 'Postcode': job.postcode, + 'Status': job.status.name, + 'Description': job.description, + 'Created At': job.created_at.strftime('%Y-%m-%d %H:%M:%S'), + }) + + df = pd.DataFrame(data) + + # Export as Excel + output = io.BytesIO() + with pd.ExcelWriter(output, engine='openpyxl') as writer: + df.to_excel(writer, index=False, sheet_name='Jobs') + + output.seek(0) + response = HttpResponse(output, content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + response['Content-Disposition'] = f'attachment; filename="jobs_export_{company.name}.xlsx"' + return response + +@login_required +def job_import(request): + profile = request.user.profile + company = profile.company + + if request.method == 'POST': + form = ImportJobsForm(request.POST, request.FILES) + if form.is_valid(): + file = request.FILES['file'] + try: + if file.name.endswith('.csv'): + df = pd.read_csv(file) + else: + df = pd.read_excel(file) + + # Basic mapping: Job Ref, UPRN, Address 1, Postcode, Status + # Expecting columns to match roughly or using a simple mapping + + starting_status = company.statuses.filter(is_starting_status=True).first() + + imported_count = 0 + errors = [] + + with transaction.atomic(): + for index, row in df.iterrows(): + job_ref = str(row.get('Job Ref', '')).strip() + if not job_ref: continue + + uprn = str(row.get('UPRN', '')).strip() if pd.notna(row.get('UPRN')) else None + addr1 = str(row.get('Address 1', '')).strip() + postcode = str(row.get('Postcode', '')).strip() + + # Find status or use default + status_name = str(row.get('Status', '')).strip() + status = company.statuses.filter(name__iexact=status_name).first() or starting_status + + try: + job, created = Job.objects.update_or_create( + company=company, + job_ref=job_ref, + defaults={ + 'uprn': uprn, + 'address_line_1': addr1, + 'postcode': postcode, + 'status': status, + } + ) + + # Initialize folders + for folder in company.required_folders.all(): + JobFolderCompletion.objects.get_or_create(job=job, folder=folder) + + imported_count += 1 + except Exception as e: + errors.append(f"Row {index+2}: {str(e)}") + + if errors: + messages.warning(request, f"Imported {imported_count} jobs with some errors: {', '.join(errors[:5])}") + else: + messages.success(request, f"Successfully imported {imported_count} jobs.") + + return redirect('job_list') + + except Exception as e: + messages.error(request, f"Error processing file: {str(e)}") + else: + form = ImportJobsForm() + + return render(request, 'core/job_import.html', {'form': form, 'company': company}) + @login_required def settings_view(request): profile = request.user.profile @@ -269,7 +406,6 @@ def status_create(request): status = form.save(commit=False) status.company = profile.company if status.is_starting_status: - # Unset other starting statuses JobStatus.objects.filter(company=profile.company).update(is_starting_status=False) status.save() messages.success(request, "New status created.") @@ -329,7 +465,6 @@ def folder_create(request): folder = form.save(commit=False) folder.company = profile.company folder.save() - # Note: The jobs will automatically see this new folder in job_detail view messages.success(request, f"Folder '{folder.name}' added company-wide.") return redirect('settings') else: @@ -347,4 +482,4 @@ def folder_delete(request, pk): folder.delete() messages.success(request, "Folder removed from company settings.") return redirect('settings') - return render(request, 'core/folder_confirm_delete.html', {'folder': folder}) \ No newline at end of file + return render(request, 'core/folder_confirm_delete.html', {'folder': folder})
{{ job.uprn }} -
{{ job.address_line_1 }}
+
{{ job.address_line_1 }}
{{ job.postcode }}
@@ -54,17 +65,24 @@ {{ job.created_at|date:"M d, Y" }} - View - Edit +
+ View + Edit +
+
- -

No jobs found. Start by creating your first repair job.

- Create Job +
+ +
+

No jobs found. Start by creating your first repair job or import them from Excel.

+