File & Photo Uploads & Excel Import/Export
This commit is contained in:
parent
50d8918174
commit
a200f58095
Binary file not shown.
Binary file not shown.
@ -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'
|
||||
@ -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)
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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'}))
|
||||
@ -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 %}
|
||||
72
core/templates/core/job_import.html
Normal file
72
core/templates/core/job_import.html
Normal 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 %}
|
||||
@ -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 %}
|
||||
45
core/urls.py
45
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/<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'),
|
||||
]
|
||||
|
||||
165
core/views.py
165
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})
|
||||
return render(request, 'core/folder_confirm_delete.html', {'folder': folder})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user