File & Photo Uploads & Excel Import/Export

This commit is contained in:
Flatlogic Bot 2026-01-21 23:12:42 +00:00
parent 50d8918174
commit a200f58095
13 changed files with 389 additions and 72 deletions

View File

@ -155,6 +155,10 @@ STATICFILES_DIRS = [
BASE_DIR / 'node_modules',
]
# Media files (Uploads)
MEDIA_URL = 'media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Email
EMAIL_BACKEND = os.getenv(
"EMAIL_BACKEND",
@ -179,4 +183,4 @@ if EMAIL_USE_SSL:
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'

View File

@ -27,3 +27,4 @@ urlpatterns = [
if settings.DEBUG:
urlpatterns += static("/assets/", document_root=settings.BASE_DIR / "assets")
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@ -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'}))

View File

@ -1,4 +1,5 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Job: {{ job.job_ref }} - RepairsHub{% endblock %}
{% block content %}
<div class="container py-5">
@ -81,24 +82,94 @@
<div class="row g-3">
{% for completion in folder_completions %}
<div class="col-md-6">
<div class="card h-100 border-0 shadow-sm {% if completion.is_completed %}border-start border-4 border-success{% endif %}">
<div class="card-body d-flex justify-content-between align-items-center py-4">
<div>
<h6 class="mb-1 fw-bold">{{ completion.folder.name }}</h6>
<p class="text-muted smaller mb-0">
<i class="bi bi-file-earmark-arrow-up"></i> 0 Files
</p>
<div class="col-md-12">
<div class="card border-0 shadow-sm {% if completion.is_completed %}border-start border-4 border-success{% endif %} mb-3">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h6 class="mb-1 fw-bold fs-5">{{ completion.folder.name }}</h6>
<p class="text-muted smaller mb-0">
<i class="bi bi-file-earmark-text"></i> {{ completion.files_list.count }} Files uploaded
</p>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill px-3" data-bs-toggle="modal" data-bs-target="#uploadModal{{ completion.folder.id }}">
<i class="bi bi-upload me-1"></i> Upload
</button>
<form action="{% url 'toggle_folder_completion' job.pk completion.folder.id %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-sm {% if completion.is_completed %}btn-success{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
{% if completion.is_completed %}
<i class="bi bi-check2-circle me-1"></i> Done
{% else %}
Mark Ready
{% endif %}
</button>
</form>
</div>
</div>
<form action="{% url 'toggle_folder_completion' job.pk completion.folder.id %}" method="post">
{% if completion.files_list %}
<div class="table-responsive">
<table class="table table-hover table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>File Name</th>
<th>Uploaded By</th>
<th>Date</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
{% for job_file in completion.files_list %}
<tr>
<td>
<a href="{{ job_file.file.url }}" target="_blank" class="text-decoration-none">
<i class="bi bi-file-earmark-arrow-down me-2"></i> {{ job_file.file.name|cut:"job_files/" }}
</a>
</td>
<td><small>{{ job_file.uploaded_by.username }}</small></td>
<td><small class="text-muted">{{ job_file.uploaded_at|date:"d/m/y H:i" }}</small></td>
<td class="text-end">
<a href="{% url 'job_delete_file' job.pk job_file.id %}" class="btn btn-link text-danger p-0" onclick="return confirm('Delete this file?')">
<i class="bi bi-trash"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-3 bg-light rounded-3">
<p class="text-muted small mb-0">No files uploaded yet.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Upload Modal -->
<div class="modal fade" id="uploadModal{{ completion.folder.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content border-0 shadow">
<div class="modal-header border-0 bg-light">
<h5 class="modal-title">Upload to {{ completion.folder.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'job_upload_file' job.pk completion.folder.id %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<button type="submit" class="btn btn-sm {% if completion.is_completed %}btn-success{% else %}btn-outline-secondary{% endif %} rounded-pill px-3">
{% if completion.is_completed %}
<i class="bi bi-check2-circle me-1"></i> Done
{% else %}
Mark Ready
{% endif %}
</button>
<div class="modal-body p-4">
<div class="mb-3">
<label class="form-label fw-bold">Select File</label>
{{ file_form.file }}
<div class="form-text">Photos, PDFs, or documents are accepted.</div>
</div>
</div>
<div class="modal-footer border-0 p-4 pt-0">
<button type="button" class="btn btn-light rounded-pill px-4" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary rounded-pill px-4">Upload File</button>
</div>
</form>
</div>
</div>
@ -134,16 +205,16 @@
</div>
</div>
<div class="card border-0 shadow-sm bg-navy-dark text-white" style="background-color: var(--navy-dark);">
<div class="card border-0 shadow-sm bg-navy-dark text-white" style="background-color: #0F172A;">
<div class="card-body p-4">
<h6 class="text-uppercase text-muted smaller fw-bold mb-3">Quick Export</h6>
<p class="small text-muted mb-4">Download this job details for offline reporting.</p>
<button class="btn btn-primary w-100 disabled" title="Coming soon">
<i class="bi bi-file-earmark-pdf me-2"></i> Export to PDF
</button>
<h6 class="text-uppercase text-muted smaller fw-bold mb-3 text-white-50">Quick Export</h6>
<p class="small text-white-50 mb-4">Download this job details for offline reporting.</p>
<a href="{% url 'job_export' %}" class="btn btn-primary w-100 rounded-pill">
<i class="bi bi-file-earmark-excel me-2"></i> Export to Excel
</a>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -0,0 +1,72 @@
{% extends 'base.html' %}
{% block title %}Import Jobs - RepairsHub{% endblock %}
{% block content %}
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-md-8 col-lg-6">
<nav aria-label="breadcrumb" class="mb-4">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'job_list' %}" class="text-decoration-none">Jobs</a></li>
<li class="breadcrumb-item active">Import</li>
</ol>
</nav>
<div class="card border-0 shadow-lg overflow-hidden">
<div class="bg-primary p-4 text-white">
<h2 class="h4 mb-1">Import Jobs</h2>
<p class="mb-0 opacity-75">Upload an Excel or CSV file to bulk create jobs.</p>
</div>
<div class="card-body p-5">
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show mb-4" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<div class="mb-5">
<h6 class="text-uppercase text-muted smaller fw-bold mb-3">Instructions</h6>
<ul class="small text-muted mb-0">
<li>Supported formats: <strong>.xlsx, .xls, .csv</strong></li>
<li>The following columns are recognized:
<ul class="mt-2">
<li><code>Job Ref</code> (Required, Unique)</li>
<li><code>UPRN</code> (Optional)</li>
<li><code>Address 1</code> (Required)</li>
<li><code>Postcode</code> (Required)</li>
<li><code>Status</code> (Optional, defaults to starting status)</li>
</ul>
</li>
<li>Duplicate <code>Job Ref</code> will update existing jobs.</li>
</ul>
</div>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
<div class="mb-4">
<label class="form-label fw-bold">{{ form.file.label }}</label>
{{ form.file }}
{% if form.file.errors %}
<div class="text-danger small mt-1">{{ form.file.errors }}</div>
{% endif %}
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg rounded-pill">
<i class="bi bi-cloud-upload me-2"></i> Process Import
</button>
<a href="{% url 'job_list' %}" class="btn btn-link text-muted">Cancel</a>
</div>
</form>
</div>
</div>
<div class="mt-4 text-center">
<p class="text-muted small">Need a template? <a href="{% url 'job_export' %}" class="text-decoration-none">Export existing jobs</a> to see the required format.</p>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -7,9 +7,20 @@
<h1 class="h2 mb-1">Repair Jobs</h1>
<p class="text-muted">Manage and track all repairs for {{ company.name }}</p>
</div>
<a href="{% url 'job_create' %}" class="btn btn-primary">
<i class="bi bi-plus-lg"></i> Create New Job
</a>
<div class="d-flex gap-2">
<div class="dropdown">
<button class="btn btn-outline-secondary dropdown-toggle" type="button" id="bulkActionsDropdown" data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-gear me-1"></i> Bulk Actions
</button>
<ul class="dropdown-menu dropdown-menu-end border-0 shadow" aria-labelledby="bulkActionsDropdown">
<li><a class="dropdown-item" href="{% url 'job_import' %}"><i class="bi bi-cloud-upload me-2 text-primary"></i> Import Jobs</a></li>
<li><a class="dropdown-item" href="{% url 'job_export' %}"><i class="bi bi-file-earmark-excel me-2 text-success"></i> Export All to Excel</a></li>
</ul>
</div>
<a href="{% url 'job_create' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i> Create New Job
</a>
</div>
</div>
{% if messages %}
@ -21,7 +32,7 @@
{% endfor %}
{% endif %}
<div class="card overflow-hidden">
<div class="card border-0 shadow-sm overflow-hidden">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
@ -44,7 +55,7 @@
<td>{{ job.uprn }}</td>
{% endif %}
<td>
<div class="small">{{ job.address_line_1 }}</div>
<div class="small fw-medium">{{ job.address_line_1 }}</div>
<div class="text-muted small">{{ job.postcode }}</div>
</td>
<td>
@ -54,17 +65,24 @@
</td>
<td class="text-muted small">{{ job.created_at|date:"M d, Y" }}</td>
<td class="text-end pe-4">
<a href="{% url 'job_detail' job.pk %}" class="btn btn-sm btn-outline-secondary">View</a>
<a href="{% url 'job_update' job.pk %}" class="btn btn-sm btn-outline-primary">Edit</a>
<div class="btn-group">
<a href="{% url 'job_detail' job.pk %}" class="btn btn-sm btn-outline-secondary">View</a>
<a href="{% url 'job_update' job.pk %}" class="btn btn-sm btn-outline-primary">Edit</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center py-5">
<td colspan="{% if company.is_uprn_required %}6{% else %}5{% endif %}" class="text-center py-5">
<div class="py-4">
<i class="bi bi-tools display-4 text-muted mb-3 d-block"></i>
<p class="text-muted">No jobs found. Start by creating your first repair job.</p>
<a href="{% url 'job_create' %}" class="btn btn-primary btn-sm mt-2">Create Job</a>
<div class="bg-light rounded-circle d-inline-flex p-4 mb-3">
<i class="bi bi-tools fs-1 text-muted"></i>
</div>
<p class="text-muted">No jobs found. Start by creating your first repair job or import them from Excel.</p>
<div class="d-flex justify-content-center gap-2 mt-3">
<a href="{% url 'job_create' %}" class="btn btn-primary btn-sm">Create Job</a>
<a href="{% url 'job_import' %}" class="btn btn-outline-secondary btn-sm">Import Excel</a>
</div>
</div>
</td>
</tr>
@ -74,4 +92,4 @@
</div>
</div>
</div>
{% endblock %}
{% endblock %}

View File

@ -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/<int:pk>/", views.job_detail, name="job_detail"),
path("jobs/<int:pk>/edit/", views.job_update, name="job_update"),
path("jobs/<int:pk>/delete/", views.job_delete, name="job_delete"),
path("jobs/<int:pk>/toggle-folder/<int:folder_id>/", 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/<int:pk>/', views.job_detail, name='job_detail'),
path('jobs/<int:pk>/edit/', views.job_update, name='job_update'),
path('jobs/<int:pk>/delete/', views.job_delete, name='job_delete'),
path('jobs/<int:pk>/toggle-folder/<int:folder_id>/', views.toggle_folder_completion, name='toggle_folder_completion'),
path('jobs/<int:pk>/upload-file/<int:folder_id>/', views.job_upload_file, name='job_upload_file'),
path('jobs/<int:pk>/delete-file/<int:file_id>/', 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/<int:pk>/edit/", views.status_update, name="status_update"),
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"),
]
path('settings/', views.settings_view, name='settings'),
path('settings/status/create/', views.status_create, name='status_create'),
path('settings/status/<int:pk>/edit/', views.status_update, name='status_update'),
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'),
]

View File

@ -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})
return render(request, 'core/folder_confirm_delete.html', {'folder': folder})